1/* common RD UI utilities 2 * dependencies: lodash 3 */ 4 5var RDJS = (function (window, document) { 6 7 'use strict'; 8 9 10 /* =helper functions 11 * ================= 12 */ 13 14 // quick no-effort polyfill for Element.matches 15 if (!Element.prototype.matches) { 16 Element.prototype.matches = Element.prototype.msMatchesSelector || 17 Element.prototype.webkitMatchesSelector; 18 } 19 20 21 // cast to array, because _.toArray and _.castArray are inadequate when 22 // the argument could be either a node or nodeList. Also doubles as 23 // an array copier 24 function toArray(collection) { 25 var result = []; 26 if (collection) { 27 if (typeof collection.length === 'number') { 28 for (var i = 0; i < collection.length; i++) { 29 result.push(collection[i]); 30 } 31 } else { 32 result.push(collection); 33 } 34 } 35 return result; 36 } 37 38 39 // get all elements not contained within certain parents 40 // because css selectors are currently inadequate to solve this problem 41 // in the general case 42 function getElementsWithoutParent(selector, parentBlacklist, currentParent) { 43 if (!currentParent) { 44 currentParent = document.documentElement; 45 } 46 47 var result = []; 48 49 if (!currentParent.children) { 50 return result; 51 } 52 53 for (var i = 0; i < currentParent.children.length; i++) { 54 if (currentParent.children[i].matches(selector)) { 55 result.push(currentParent.children[i]); 56 } 57 if (!currentParent.children[i].matches(parentBlacklist)) { 58 result = result.concat(getElementsWithoutParent(selector, parentBlacklist, currentParent.children[i])); 59 } 60 } 61 return result; 62 } 63 64 65 // return a valid url-encoded string of parameters from an object 66 // currently does not support nested objects 67 function objectToParameters(obj) { 68 var parts = []; 69 for (var key in obj) { 70 if (obj.hasOwnProperty(key)) { 71 parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])); 72 } 73 } 74 return parts.join('&'); 75 } 76 77 78 // produce a valid html id (with some additional considerations) from an 79 // arbitrary string. Providing a value for 'defaultId' will cover cases where 80 // the string is blank or consists entirely of characters we will strip out 81 function generateHtmlId(str, defaultId) { 82 // sanitize our id value a bit. The html5 spec is /very/ permissive about 83 // what can be in an id (pretty much anything but spaces goes), but strip 84 // out some additional characters in case we're using this as a url fragment 85 var idString = str 86 .trim() 87 .replace(/\s+/g, '-') 88 .replace(/[\.\?\$\/ #&%@,]/g, '') 89 .toLowerCase(); 90 91 idString = idString || defaultId || ''; // in case str was empty or all special chars 92 93 // if we have an id conflict, increment until we don't. We could append 94 // some kind of random value, but we want this to produce the same 95 // linkable hash each run 96 var tryCount = 0; 97 var existingElement = document.getElementById(idString); 98 while (existingElement) { 99 tryCount++; 100 existingElement = document.getElementById(idString + '-' + String(tryCount)); 101 } 102 if (tryCount > 0) { 103 idString += '-' + String(tryCount); 104 } 105 106 return idString; 107 } 108 109 110 // xhr helper 111 function xhr(method, url, callback, data) { 112 var _validMethods = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'], 113 xhr = new XMLHttpRequest(); 114 115 if (!method) { 116 console.error('No method provided to xhr'); 117 return false; 118 } 119 method = method.toUpperCase(); 120 if (!_validMethods.includes(method)) { 121 console.error(method + ' is not a valid http request method'); 122 return false; 123 } 124 125 if (!url) { 126 console.error('No url provided to xhr'); 127 return false; 128 } 129 130 xhr.onreadystatechange = function () { 131 if (xhr.readyState === XMLHttpRequest.DONE) { 132 if (xhr.status === 200) { 133 if (callback && typeof callback === 'function') { 134 callback(xhr.responseText); 135 } else { 136 console.log(xhr.responseText); 137 } 138 } else { 139 console.error('XHR returned a status of ' + xhr.status); 140 console.log(xhr); 141 } 142 } 143 }; 144 145 xhr.open(method, url); 146 147 if (method === 'POST' && data) { 148 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 149 if (typeof data === 'object') { 150 xhr.send(objectToParameters(data)); 151 } else { 152 xhr.send(data); 153 } 154 } else { 155 xhr.send(); 156 } 157 } 158 159 160 /* =toggles 161 * ======== 162 * 163 * a one-to-many class toggler activated on click 164 * 165 * default elements:
166 * <el data-toggle-target="[keyword|selector]" /> 167 * 168 * external toggle interactions 169 * <el data-toggle-exit="esc outside" /> 170 */ 171 172 var toggles = (function () { 173 var toggleList = [], 174 175 togglePrototype = { 176 origin: null, 177 targets: [], 178 activeClass: 'is-active', 179 deactivateOnEsc: false, 180 deactivateOnOutside: false, 181 useFragments: false, 182 useOriginAria: true, 183 useTargetAria: true, 184 185 // make toggle go 186 activate: function activate(force) { 187 _.forEach([this.origin].concat(this.targets), function (el, i) { 188 if (typeof force === 'boolean') { 189 el.classList.toggle(this.activeClass, force); 190 } else { 191 el.classList.toggle(this.activeClass); 192 } 193 // if this is the origin, set control state 194 if (i === 0 && this.useOriginAria) { 195 el.setAttribute('aria-expanded', (el.classList.contains(this.activeClass)).toString()); 196 // if this is a toggle, set aria hidden state 197 } else if (this.useTargetAria) { 198 el.setAttribute('aria-hidden', (!el.classList.contains(this.activeClass)).toString()); 199 } 200 }.bind(this)); 201 202 // focus first relevant target as a convenience to kb/sr users 203 if (this.origin.classList.contains(this.activeClass) && 204 this.targets.length > 0 && 205 this.targets[0].tagName.toLowerCase() !== 'body') { 206 const focusableEl = this.targets[0].querySelector('a, input, select'); 207 if (focusableEl) { 208 // allow some time for transitions to reveal focusable elements 209 // so browsers will actually focus them 210 setTimeout(function () {
211 focusableEl.focus(); 212 }, 50); 213 } 214 } 215 216 // write url fragment, if applicable 217 if (this.useFragments && this.origin.id) { 218 if (this.origin.classList.contains(this.activeClass)) { 219 window.history.replaceState(null, '', '#' + encodeURIComponent(this.origin.id)); 220 } else { 221 window.history.replaceState(null, '', window.location.href.split('#')[0]); 222 } 223 } 224 225 if (typeof this.onActivate === 'function') { 226 this.onActivate(this); 227 } 228 229 return this; 230 }, 231 232 // get targets, generate a11y attributes, and add event listeners 233 init: function init() { 234 this.targets = toArray(this.targets); 235 236 // set up ids 237 this.origin.id = this.origin.id || generateHtmlId(this.origin.textContent.substr(0, 24), 'toggle'); 238 _.forEach(this.targets, function generateTargetId(target) { 239 target.id = target.id || generateHtmlId(this.origin.textContent.substr(0, 24) + '-pane', 'pane'); 240 }.bind(this)); 241 242 // set initial a11y attributes 243 this.origin.setAttribute('role', 'button'); 244 if (this.useOriginAria) { 245 this.origin.setAttribute('aria-expanded', (this.origin.classList.contains(this.activeClass)).toString()); 246 this.origin.setAttribute('aria-controls', this.targets[0].id); 247 } 248 if (this.useTargetAria) { 249 _.forEach(this.targets, function linkTargetToOrigin(target) { 250 target.setAttribute('aria-labelledby', this.origin.id); 251 target.setAttribute('aria-hidden', (!this.origin.classList.contains(this.activeClass)).toString()); 252 }.bind(this)); 253 } 254 255 // make toggle focusable if it isn't a focusable element by default 256 this.origin.setAttribute('tabindex', '0'); 257 258 // allow programmatic focus on first target content element 259 if (this.targets.length > 0) { 260 this.targets[0].setAttribute('tabindex', '-1'); 261 } 262 263 // bind listeners 264 this.origin.addEventListener('click', function (ev) { 265 ev.preventDefault(); 266 this.activate(); 267 }.bind(this)); 268 this.origin.addEventListener('keydown', function (ev) { 269 // bind spacebar and return in case we're not using a native 270 // button element that includes these interactions implicitly 271 if (ev.keyCode === 13 || ev.keyCode === 32) { 272 ev.preventDefault(); 273 this.activate(); 274 } 275 }.bind(this)); 276 277 if (typeof this.onInit === 'function') { 278 this.onInit(this); 279 } 280 281 return this; 282 }, 283 284 // optional callbacks 285 onActivate: null, 286 onInit: null 287 }; 288 289 // private: parses a target string and returns a matching array 290 function findTargets(el) { 291 var targetString = el.getAttribute('data-toggle-target'); 292 if (!targetString) { 293 return []; 294 } else if (targetString === 'next') { 295 return [el.nextElementSibling]; 296 } else if (targetString === 'parent') { 297 return [el.parentNode]; 298 } else if (targetString === 'parentparent') { 299 return [el.parentNode.parentNode]; 300 } else if (targetString === 'parentnext') { 301 return [el.parentNode.nextElementSibling]; 302 } else if (targetString === 'parentparentnext') { 303 return [el.parentNode.parentNode.nextElementSibling]; 304 } 305 return toArray(document.querySelectorAll(targetString)); 306 } 307 308 // public: make node or nodeList toggleable 309 function add(els, options) { 310 options = options || {}; 311 els = toArray(els); 312 _.forEach(els, function (el) { 313 var toggle = _.assign(Object.create(togglePrototype), { origin: el }, options); 314 toggleList.push(toggle);
315 toggle.init(); 316 }); 317 return list(); 318 } 319 320 // public: returns an array of all toggles on the page 321 function list() { 322 return toggleList; 323 } 324 325 // public: returns all toggle objects whose origins match a given css selector 326 function find(selector) { 327 var result = []; 328 _.forEach(list(), function (toggle) { 329 if (toggle.origin.matches(selector)) { 330 result.push(toggle); 331 } 332 }); 333 return result; 334 } 335 336 // public: returns a single toggle object whose origin matches a given css selector 337 function get(selector) { 338 var result = null; 339 _.forEach(list(), function (toggle) { 340 if (toggle.origin.matches(selector)) { 341 result = toggle; 342 return false; 343 } 344 }); 345 return result; 346 } 347 348 // public: initialize by adding default elements 349 function init() { 350 _.forEach(document.querySelectorAll('[data-toggle-target]'), function addNewToggle(el) { 351 const targets = findTargets(el); 352 var options = { 353 targets: targets, 354 deactivateOnEsc: (/\besc\b/.test(el.getAttribute('data-toggle-exit'))), 355 deactivateOnOutside: (/\boutside\b/.test(el.getAttribute('data-toggle-exit'))), 356 useFragments: Boolean(el.getAttribute('data-toggle-fragment')), 357 useTargetAria: targets.filter(el => el.tagName.toLowerCase() === 'body').length <= 0, 358 }; 359 add(el, options); 360 }); 361 362 // open any toggles that match url fragment 363 if (window.location.hash) { 364 var urlFragment = decodeURIComponent(window.location.hash.substr(1)); 365 _.forEach(list(), function matchGroupFragment(group) { 366 _.forEach([group.origin].concat(group.targets), function matchElementFragment(el) { 367 if (el.id === urlFragment) { 368 group.activate(true); 369 } 370 }); 371 }); 372 } 373 374 // add global close listeners 375 document.documentElement.addEventListener('keyup', function (ev) { 376 if (ev.keyCode === 27) { 377 _.forEach(list(), function (toggle) { 378 if (toggle.deactivateOnEsc) { 379 toggle.activate(false); 380 } 381 }); 382 } 383 }); 384 document.body.addEventListener('click', function (ev) { 385 _.forEach(list().filter(function (toggle) { return toggle.deactivateOnOutside; }), function (toggle) { 386 var deactivate = true, 387 els = [toggle.origin].concat(toggle.targets); 388 for (var i = els.length - 1; i >= 0; i--) { 389 if (els[i] === ev.target || els[i].contains(ev.target)) { 390 deactivate = false; 391 break; 392 } 393 } 394 if (deactivate) { 395 toggle.activate(false); 396 } 397 }); 398 }); 399 } 400 401 return { 402 add: add, 403 list: list, 404 find: find, 405 get: get, 406 init: init 407 }; 408 }()); 409 410 411 /* =tabs 412 * ===== 413 * 414 * a class switcher wherein only one element (or pair of elements) 415 * may be active at a time 416 * 417 * default elements: 418 * <el class="tabs [is-automated]?"> 419 * <el class="tabs__tab" /> 420 * <el class="tabs__pane" /> 421 * </el> 422 */ 423 424 425 var tabs = (function () { 426 var tabGroupList = [], 427 428 tabGroupPrototype = { 429 // preferences 430 automated: false, 431 slideDuration: 5000, 432 useFragments: false, 433 434 // calculated attributes 435 active: 0, 436 count: 0, 437 438 // elements 439 container: null, 440 tabs: null, 441 panes: null, 442 nextTriggers: null, 443 previousTriggers: null, 444 playTriggers: null, 445 pauseTriggers: null, 446 447 // default (overrideable) selectors used in init() 448 containerSelector: '.tabs', 449 tabSelector: '.tabs__tab', 450 paneSelector: '.tabs__pane', 451 nextSelector: '.tabs__next', 452 previousSelector: '.tabs__previous', 453 playSelector: '.tabs__play', 454 pauseSelector: '.tabs__pause', 455 456 // classes 457 activeClass: 'is-active', 458 lastActiveClass: 'is-last-active', 459 nextClass: 'is-next', 460 previousClass: 'is-previous', 461 initClass: 'is-enhanced', 462 playingClass: 'is-playing', 463 464 // utility 465 timer: null, 466 467 changeTo: function changeTo(index, interruptTimer, isFirstLoad) { 468 if (index === 'next') { 469 index = this.getNextIndex(); 470 } else if (index === 'previous') { 471 index = this.getPreviousIndex(); 472 } else if (typeof index !== 'number' || index < 0 || index >= this.count) {
473 console.warn('given index not recognized'); 474 return this; 475 } 476 477 if (interruptTimer && this.automated) { 478 this.pause(); 479 } 480 481 // reset state classes to blank slate 482 _.forEach(this.tabs.concat(this.panes), function (el) { 483 el.classList.remove(this.activeClass); 484 el.classList.remove(this.lastActiveClass); 485 el.classList.remove(this.nextClass); 486 el.classList.remove(this.previousClass); 487 }.bind(this)); 488 489 // reset aria attributes to defaults 490 _.forEach(this.tabs, function (tab) { 491 tab.setAttribute('aria-selected', 'false'); 492 }.bind(this)); 493 _.forEach(this.panes, function (pane) { 494 pane.setAttribute('aria-hidden', 'true'); 495 }.bind(this)); 496 497 // mark the tab we're changing /from/ for the purposes of animation 498 if (this.active !== index) { 499 if (this.tabs[this.active]) { 500 this.tabs[this.active].classList.add(this.lastActiveClass); 501 } 502 if (this.panes[this.active]) { 503 this.panes[this.active].classList.add(this.lastActiveClass); 504 } 505 } 506 507 // make it so 508 this.active = index; 509 510 // add all state classes and aria states 511 if (this.tabs[index]) { 512 this.tabs[index].classList.add(this.activeClass); 513 this.tabs[index].setAttribute('aria-selected', 'true'); 514 } 515 if (this.panes[index]) { 516 this.panes[index].classList.add(this.activeClass); 517 this.panes[index].setAttribute('aria-hidden', 'false'); 518 } 519 if (this.tabs[this.getNextIndex()]) { 520 this.tabs[this.getNextIndex()].classList.add(this.nextClass); 521 } 522 if (this.panes[this.getNextIndex()]) { 523 this.panes[this.getNextIndex()].classList.add(this.nextClass); 524 } 525 if (this.tabs[this.getPreviousIndex()]) { 526 this.tabs[this.getPreviousIndex()].classList.add(this.previousClass); 527 } 528 if (this.panes[this.getPreviousIndex()]) { 529 this.panes[this.getPreviousIndex()].classList.add(this.previousClass); 530 } 531 532 // handle fragment ids 533 // no need to do this on initial page load, because either it's loading the default 534 // (first) tab or we're loading from an existing fragment specifier 535 if (this.useFragments && this.tabs[index] && this.tabs[index].id && !isFirstLoad) { 536 window.history.replaceState(null, '', '#' + encodeURIComponent(this.tabs[index].id)); 537 } 538 539 // focus first element inside selected pane for a better screen-reader experience 540 if (!isFirstLoad && !this.automated && this.panes[index] && this.panes[index].children.length > 0) { 541 this.panes[index].children[0].setAttribute('tabindex', '-1'); 542 this.panes[index].children[0].focus(); 543 } 544 545 if (interruptTimer && this.automated) { 546 this.play(); 547 } 548 if (typeof this.onChange === 'function') { 549 this.onChange(this); 550 } 551 return this; 552 }, 553 554 play: function play() { 555 this.container.classList.add(this.playingClass); 556 if (this.timer > 0) { 557 return false; 558 } 559 this.timer = setInterval(function () { 560 this.changeTo('next'); 561 }.bind(this), this.slideDuration); 562 if (typeof this.onPlay === 'function') { 563 this.onPlay(this); 564 } 565 return this; 566 }, 567 568 pause: function pause() { 569 this.container.classList.remove(this.playingClass); 570 clearInterval(this.timer); 571 this.timer = 0; 572 if (typeof this.onPause === 'function') { 573 this.onPause(this); 574 } 575 return this; 576 }, 577 578 getNextIndex: function getNextIndex() { 579 return (this.active + 1) % this.count; 580 }, 581 582 getPreviousIndex: function getPreviousIndex() { 583 return this.active <= 0 ? this.count - 1 : this.active - 1; 584 }, 585 586 generateTabIds: function generateTabIds() { 587 _.forEach(this.tabs, function generateTabId(el) { 588 if (el.id) return false; // skip if an id has been provided for us 589 el.id = generateHtmlId(el.textContent, 'tab'); 590 }); 591 }, 592 593 generateAriaAttributes: function generateAriaAttributes() { 594 for (var i = 0; i < this.count; i++) { 595 // make sure each pane also has an id 596 // if an id is not present, generate from the associated tab id 597 if (this.tabs[i] && this.panes[i]) { 598 if (!this.panes[i].id) { 599 this.panes[i].id = this.tabs[i].id + '-pane'; 600 } 601 602 // make sure each tab is linked to its associated pane 603 if (this.tabs[i].tagName.toLowerCase() == 'a') { 604 this.tabs[i].href = '#' + this.panes[i].id; 605 } 606 this.tabs[i].setAttribute('aria-controls', this.panes[i].id); 607 608 // make sure each pane is labeled by its associated tab 609 this.panes[i].setAttribute('aria-labelledby', this.tabs[i].id); 610 } 611 } 612 }, 613
614 init: function init() { 615 // default elements 616 this.tabs = toArray(this.tabs || 617 getElementsWithoutParent(this.tabSelector, this.containerSelector, this.container)); 618 this.panes = toArray(this.panes || 619 getElementsWithoutParent(this.paneSelector, this.containerSelector, this.container)); 620 this.nextTriggers = toArray(this.nextTriggers || 621 getElementsWithoutParent(this.nextSelector, this.containerSelector, this.container)); 622 this.previousTriggers = toArray(this.previousTriggers || 623 getElementsWithoutParent(this.previousSelector, this.containerSelector, this.container)); 624 this.playTriggers = toArray(this.playTriggers || 625 getElementsWithoutParent(this.playSelector, this.containerSelector, this.container)); 626 this.pauseTriggers = toArray(this.pauseTriggers || 627 getElementsWithoutParent(this.pauseSelector, this.containerSelector, this.container)); 628 629 // get final tabs count 630 this.count = Math.max(this.tabs.length, this.panes.length); 631 632 // generate tab ids for a11y and fragment links 633 this.generateTabIds(); 634 635 // link tabs and panes in markup for screen reader benefit 636 this.generateAriaAttributes(); 637 638 // apply count as container class to help css sizing 639 this.container.classList.add('tabs--' + String(this.count)); 640 641 // attach event listeners 642 _.forEach(this.tabs, function (tab, index) { 643 tab.setAttribute('tabindex', '0'); 644 tab.addEventListener('click', function (ev) { 645 ev.preventDefault(); 646 this.changeTo(index, true); 647 }.bind(this)); 648 tab.addEventListener('keydown', function (ev) { 649 if (ev.keyCode === 13 || ev.keyCode === 32) { 650 ev.preventDefault(); 651 this.changeTo(index, true); 652 } 653 }.bind(this)); 654 }.bind(this)); 655 _.forEach(this.nextTriggers, function (el) { 656 el.addEventListener('click', function (ev) { 657 ev.preventDefault(); 658 this.changeTo('next', true); 659 }.bind(this)); 660 }.bind(this)); 661 _.forEach(this.previousTriggers, function (el) { 662 el.addEventListener('click', function (ev) { 663 ev.preventDefault(); 664 this.changeTo('previous', true); 665 }.bind(this)); 666 }.bind(this)); 667 _.forEach(this.pauseTriggers, function (el) { 668 el.addEventListener('click', function (ev) { 669 ev.preventDefault(); 670 this.pause(); 671 }.bind(this)); 672 }.bind(this)); 673 _.forEach(this.playTriggers, function (el) { 674 el.addEventListener('click', function (ev) { 675 ev.preventDefault(); 676 this.play(); 677 }.bind(this)); 678 }.bind(this)); 679 680 // automated slideshows get paused on mouseover 681 if (this.automated) { 682 this.container.addEventListener('mouseenter', this.pause.bind(this)); 683 this.container.addEventListener('mouseleave', this.play.bind(this)); 684 } 685 686 // if we have a fragment specifier, use it; otherwise, the first tab is open by default 687 if (this.useFragments && window.location.hash) { 688 var fragment = decodeURIComponent(window.location.hash.substr(1)); 689 for (var i = 0; i < this.tabs.length; i++) { 690 if (this.tabs[i].id === fragment) { 691 this.active = i; 692 break; 693 } 694 } 695 } 696 697 this.changeTo(this.active, false, true); 698 699 if (this.automated) { 700 this.play(); 701 } 702 703 if (typeof this.onInit === 'function') { 704 this.onInit(this); 705 } 706 707 this.container.classList.add(this.initClass); 708 709 return this; 710 }, 711 712 // optional callbacks 713 onChange: null, 714 onPlay: null, 715 onPause: null, 716 onInit: null 717 }; 718 719 // public: make node or nodeList into tab groups 720 function add(els, options) { 721 options = options || {}; 722 els = toArray(els); 723 _.forEach(els, function (el) { 724 var tabGroup = _.assign(Object.create(tabGroupPrototype), { container: el }, options); 725 tabGroupList.push(tabGroup); 726 tabGroup.init(); 727 }); 728 return list(); 729 } 730 731 // public: returns an array of all tab groups on the page 732 function list() { 733 return tabGroupList; 734 } 735 736 // public: returns all tab groups whose containers match a given css selector 737 function find(selector) { 738 var result = []; 739 _.forEach(list(), function (tabGroup) { 740 if (tabGroup.container.matches(selector)) { 741 result.push(tabGroup); 742 } 743 }); 744 return result; 745 } 746 747 // public: returns a single tab group whose container matches a given css selector 748 function get(selector) { 749 var result = null; 750 _.forEach(list(), function (tabGroup) { 751 if (tabGroup.container.matches(selector)) { 752 result = tabGroup; 753 return false;
754 } 755 }); 756 return result; 757 } 758 759 // public: initialize by adding default elements 760 function init() { 761 _.forEach(toArray(document.getElementsByClassName('tabs')), function addTabsContainer(container) { 762 add(container, { 763 automated: container.matches('.tabs--automated'), 764 useFragments: container.matches('.tabs--use-fragments'), 765 }); 766 }); 767 } 768 769 return { 770 add: add, 771 list: list, 772 find: find, 773 get: get, 774 init: init 775 }; 776 }()); 777 778 779 /* =shared height elements 780 * ======================= 781 * 782 * match minimum heights across disparate elements 783 * 784 * usage: 785 * <el data-height-group="groupname" /> 786 */ 787 788 var sharedHeights = (function () { 789 var heightGroups = {}; 790 791 // public: update all shared-height elements 792 function update() { 793 var g, thisGroup, heights, maxHeight; 794 795 function resetHeight(el) { 796 el.style.minHeight = 0; 797 heights.push(el.offsetHeight); 798 } 799 function applyHeight(el) { 800 el.style.minHeight = maxHeight + 'px'; 801 } 802 803 for (g in heightGroups) { 804 if (heightGroups.hasOwnProperty(g)) { 805 thisGroup = heightGroups[g]; 806 heights = []; 807 _.forEach(thisGroup, resetHeight); 808 maxHeight = Math.max.apply(null, heights); 809 _.forEach(thisGroup, applyHeight); 810 } 811 } 812 } 813 814 // public: add shared-height functionality to a node or nodelist 815 function add(els, groupName) { 816 _.forEach(toArray(els), function (el) { 817 var group = groupName || el.getAttribute('data-height-group'); 818 if (!group) { 819 console.warn('No group specified for shared-height element'); 820 return false; 821 } 822 heightGroups[group] = heightGroups[group] || []; 823 heightGroups[group].push(el); 824 }); 825 return list(); 826 } 827 828 // public: return heightGroups object 829 function list() { 830 return heightGroups; 831 } 832 833 // public: initialize with default elements 834 function init() { 835 add(document.querySelectorAll('[data-height-group]')); 836 update(); 837 } 838 839 var debouncedUpdate = _.debounce(update, 75); 840 window.addEventListener('resize', debouncedUpdate); 841 window.addEventListener('load', debouncedUpdate); 842 843 return { 844 add: add, 845 list: list, 846 update: update, 847 init: init 848 }; 849 }()); 850 851 852 /* =waypoints 853 * ========== 854 * 855 * a scrolling waypoint-based class switcher, for when skrollr is overkill 856 * 857 * default elements: 858 * <el data-waypoints="waypoint[ persist]?: class" /> 859 * where waypoint is a number and a unit of measurement (%, px, em). 860 * multiple waypoints may be separated by commas 861 */ 862 863 var waypoints = (function () { 864 var waypointElementList = [], 865 waypointElementPrototype = { 866 el: null, 867 waypoints: [], 868 869 init: function () { 870 var configString = this.el.getAttribute('data-waypoints'); 871 if (!configString) { return false; } 872 873 _.forEach(configString.split(','), function (config) { 874 var configParts = config.split(':'), 875 val, cl, unit, persist = false, waypoint; 876 877 try { 878 val = configParts[0].trim(); 879 cl = configParts[1].trim(); 880 if (val.substr(-7) === 'persist') { 881 persist = true; 882 val = val.slice(0, -7).trim(); 883 } 884 unit = val.replace(/\d|,/g, '') || '%'; 885 val = parseFloat(val); 886 if (!cl || isNaN(val) || typeof val !== 'number') { 887 throw 'Bad config'; 888 } 889 waypoint = _.assign(Object.create(waypointPrototype), { 890 unit: unit, 891 val: val, 892 cl: cl, 893 persistent: persist 894 }); 895 this.waypoints.push(waypoint); 896 } catch (e) { 897 console.warn('Bad inline configuration provided to waypoints in element ', this.el, ' at waypoint ', configParts[0]); 898 } 899 }.bind(this)); 900 } 901 }, 902 903 waypointPrototype = { 904 val: 50, 905 unit: '%', 906 cl: '', 907 persistent: false 908 }; 909 910 function scrollListener() {
911 var viewportHeight = document.documentElement.clientHeight; 912 _.forEachRight(waypointElementList, function (waypointElement, elementIndex) { 913 var offset = waypointElement.el.getBoundingClientRect().top - viewportHeight, 914 scroll = window.scrollY; 915 if (offset < 0 && window.scrollY > 0) { 916 _.forEachRight(waypointElement.waypoints, function (waypoint, waypointIndex) { 917 var test = false; 918 switch (waypoint.unit) { 919 case '%': 920 var percentage = -offset / waypointElement.el.offsetHeight * 100; 921 test = percentage > waypoint.val; 922 break; 923 case 'em': 924 var elementFontSize = parseFloat(getComputedStyle(waypointElement.el).fontSize); 925 test = -offset > elementFontSize * waypoint.val; 926 break; 927 case 'rem': 928 var documentFontSize = parseFloat(getComputedStyle(document.body).fontSize); 929 test = -offset > documentFontSize * waypoint.val; 930 break; 931 case 'vh': 932 test = -offset > viewportHeight / 100 * waypoint.val; 933 break; 934 default: // px 935 test = -offset > waypoint.val; 936 break; 937 } 938 if (test) { 939 waypointElement.el.classList.add(waypoint.cl); 940 941 // no need to continue tracking persistent elements after activation 942 if (waypoint.persistent) { 943 waypointElement.waypoints.splice(waypointIndex, 1); 944 } 945 } else { 946 waypointElement.el.classList.remove(waypoint.cl); 947 } 948 }); 949 } else { 950 _.forEach(waypointElement.waypoints, function (waypoint) { 951 waypointElement.el.classList.remove(waypoint.cl); 952 }); 953 } 954 955 // prune inactive waypoint elements 956 if (waypointElement.waypoints.length === 0) { 957 waypointElementList.splice(elementIndex, 1); 958 } 959 }); 960 } 961 962 // public: add elements to scroll tracking 963 function add(els, options) { 964 options = options || {}; 965 els = toArray(els); 966 _.forEach(els, function (el) { 967 var waypointElement = _.assign(Object.create(waypointElementPrototype), { el: el, waypoints: [] }, options); 968 waypointElementList.push(waypointElement); 969 waypointElement.init(); 970 }); 971 return list(); 972 } 973 974 // public: returns an array of all elements currently tracked by waypoints 975 function list() { 976 return waypointElementList; 977 } 978 979 // public: track default elements and add scroll listener 980 function init() { 981 _.forEach(document.querySelectorAll('[data-waypoints]'), add); 982 // do initial pass 983 scrollListener(); 984 // debounce function and listen 985 window.addEventListener('scroll', _.throttle(scrollListener, 150)); 986 } 987 988 return { 989 init: init, 990 list: list, 991 add: add 992 }; 993 }()); 994 995 996 /* =social share links 997 * =================== 998 * 999 * unobtrusive 'share' buttons that don't use too much bandwidth or create 1000 * privacy concerns 1001 * 1002 * default elements: 1003 * <div data-share-to="facebook twitter google linkedin reddit pinterest tumblr email"></div> 1004 */ 1005 1006 var sharing = (function () { 1007 1008 // glean some default parameters from the page 1009 var defaultOptions = { 1010 services: 'facebook twitter linkedin email', 1011 url: (function () { 1012 var link = document.querySelector('link[rel="canonical"]'), 1013 og = document.querySelector('meta[property="og:url"]'); 1014 if (link) { 1015 return link.getAttribute('href'); 1016 } else if (og) { 1017 return og.getAttribute('content'); 1018 } 1019 return window.location.href; 1020 })(), 1021 title: (function () { 1022 var og = document.querySelector('meta[property="og:title"]'); 1023 if (og) { 1024 return og.getAttribute('content'); 1025 } 1026 return document.title; 1027 })(), 1028 abstract: (function () { 1029 var og = document.querySelector('meta[property="og:description"]'), 1030 meta = document.querySelector('meta[name="description"]'); 1031 if (og) { 1032 return og.getAttribute('content'); 1033 } else if (meta) { 1034 return meta.getAttribute('content'); 1035 } 1036 return null; 1037 })(), 1038 thumbnail: (function () { 1039 var og = document.querySelector('meta[property="og:image"]'), 1040 apple = document.querySelector('link[rel="apple-touch-icon-precomposed"]'); 1041 if (og) { 1042 return og.getAttribute('content'); 1043 } else if (apple) { 1044 return apple.getAttribute('href'); 1045 } 1046 return null; 1047 })(), 1048 cite: (function () { 1049 var og = document.querySelector('meta[property="og:site_name"]'), 1050 link = document.querySelector('link[rel="start"][title]'); 1051 if (og) { 1052 return og.getAttribute('content'); 1053 } else if (link) { 1054 return link.getAttribute('title'); 1055 } 1056 return null; 1057 })() 1058 }; 1059 1060 // generate services and their params using passed-in options data 1061 function generateServiceData(options) { 1062 options = options || {}; 1063 return { 1064 facebook: { 1065 name: 'Facebook', 1066 icon: 'fab fa-facebook-square', 1067 url: 'https://www.facebook.com/sharer/sharer.php', 1068 params: [ 1069 { name: 'u', value: options.url }, 1070 { name: 'title', value: options.title }, 1071 { name: 'quote', value: options.text }, 1072 { name: 'description', value: options.abstract }, 1073 { name: 'caption', value: options.cite }, 1074 { name: 'picture', value: options.thumbnail } 1075 ] 1076 }, 1077 twitter: { 1078 name: 'Twitter', 1079 icon: 'fab fa-twitter-square', 1080 url: 'https://twitter.com/intent/tweet', 1081 params: [ 1082 { name: 'url', value: options.url }, 1083 { name: 'text', value: options.text } 1084 ] 1085 }, 1086 google: { 1087 name: 'Google Plus', 1088 icon: 'fab fa-google-plus-square', 1089 url: 'https://plus.google.com/share', 1090 params: [ 1091 { name: 'url', value: options.url } 1092 ] 1093 }, 1094 linkedin: { 1095 name: 'LinkedIn', 1096 icon: 'fab fa-linkedin', 1097 url: 'https://www.linkedin.com/shareArticle', 1098 params: [ 1099 { name: 'url', value: options.url }, 1100 { name: 'mini', value: 'true' }, 1101 { name: 'title', value: options.title }, 1102 { name: 'summary', value: options.abstract }, 1103 { name: 'source', value: options.cite } 1104 ] 1105 }, 1106 reddit: { 1107 name: 'Reddit', 1108 icon: 'fab fa-reddit-square', 1109 url: 'https://www.reddit.com/submit', 1110 params: [ 1111 { name: 'url', value: options.url }, 1112 { name: 'title', value: options.title } 1113 ] 1114 }, 1115 pinterest: { 1116 name: 'Pinterest', 1117 icon: 'fab fa-pinterest-square', 1118 url: 'https://pinterest.com/pin/create/button/', 1119 params: [ 1120 { name: 'url', value: options.url }, 1121 { name: 'media', value: options.thumbnail }, 1122 { name: 'description', value: options.title } 1123 ] 1124 }, 1125 tumblr: { 1126 name: 'Tumblr', 1127 icon: 'fab fa-tumblr-square', 1128 url: 'https://www.tumblr.com/widgets/share/tool', 1129 params: [ 1130 { name: 'canonicalUrl', value: options.url }, 1131 { name: 'title', value: options.title }, 1132 { name: 'caption', value: options.abstract } 1133 ] 1134 }, 1135 email: { 1136 name: 'Email', 1137 icon: 'fas fa-envelope-square', 1138 url: 'mailto:', 1139 params: [ 1140 { name: 'subject', value: options.title }, 1141 { name: 'body', value: options.url + '\n' + (options.text || '') }, 1142 ] 1143 } 1144 }; 1145 } 1146 1147 // populate our container with share buttons 1148 function generateMarkup(el, options) { 1149 var serviceData = generateServiceData(options), 1150 button = document.createElement('a'); 1151 button.setAttribute('target', '_blank'); 1152 button.className = 'share-buttons__button'; 1153 1154 _.forEach(options.services.split(/\s+/), function (service) { 1155 if (!service) return false;
1156 1157 var thisButton = button.cloneNode(true), 1158 thisData = serviceData[service]; 1159 1160 if (!thisData) return false; 1161 1162 thisButton.innerHTML = '<span class="' + thisData.icon + '"></span>'; 1163 thisButton.setAttribute('title', 'Share via ' + thisData.name); 1164 thisButton.href = thisData.url + '?' + thisData.params.map(function (param) { 1165 if (param.value) { 1166 return encodeURIComponent(param.name) + '=' + encodeURIComponent(param.value); 1167 } 1168 return null; 1169 }).filter(function (param) { 1170 return !!param; 1171 }).join('&'); 1172 1173 el.appendChild(thisButton); 1174 }); 1175 } 1176 1177 // add share buttons to a container 1178 function add(els, options) { 1179 els = toArray(els); 1180 options = options || {}; 1181 _.forEach(els, function (el) { 1182 // look for 'data-share-to' attribute on container if no services are passed 1183 var declaredServices = {}; 1184 if (!options.services && el.getAttribute('data-share-to')) { 1185 declaredServices.services = el.getAttribute('data-share-to'); 1186 } 1187 generateMarkup(el, _.assign(defaultOptions, options, declaredServices)); 1188 }); 1189 } 1190 1191 // initialize with default elements 1192 function init() { 1193 add(document.querySelector('[data-share-to]')); 1194 } 1195 1196 return { 1197 init: init, 1198 add: add 1199 }; 1200 })(); 1201 1202 1203 /* =truncators 1204 * =========== 1205 * 1206 * truncate containers to number of lines, fixed height, and so on 1207 * 1208 * usage: 1209 * <div data-truncate-to="5 lines" data-truncate-text="Show More"></div> 1210 * <div data-truncate-to="100px"></div> 1211 * <div data-truncate-to="2em"></div> 1212 * <div data-truncate-to="3 items"></div> 1213 * etc. 1214 */ 1215 1216 var truncators = (function () { 1217 var buttonPrototype = document.createElement('button'), 1218 truncatorList = [], 1219 1220 truncatorPrototype = { 1221 container: null, 1222 button: null, 1223 buttonText: "Show More", 1224 expandedButtonText: "Show Less", 1225 limit: null, 1226 limitUnit: null, 1227 isRepeatable: false, 1228 isTruncated: false, 1229 1230 init: function () { 1231 var configString = this.container.getAttribute('data-truncate-to'); 1232 1233 // parse units
1234 if ((!this.limit || !this.limitUnit) && configString) { 1235 this.limit = parseInt((configString.match(/^\d+/) || [''])[0], 10); 1236 this.limitUnit = (configString.match(/[a-z]+$/i) || [''])[0].trim(); 1237 } 1238 if (!this.limit || isNaN(this.limit) || !this.limitUnit) { 1239 console.error("Bad limit or unit config provided to following truncator:", this.container); 1240 return; 1241 } 1242 1243 if (!this.button) { 1244 this.button = buttonPrototype.cloneNode(true); 1245 } 1246 1247 this.truncate(); 1248 1249 this.button.addEventListener('click', function (ev) { 1250 if (this.isTruncated) { 1251 this.expand(); 1252 } else { 1253 this.truncate(); 1254 } 1255 }.bind(this)); 1256 }, 1257 1258 truncate: function () { 1259 var normalizedLimit, 1260 buttonSuffix = ''; 1261 1262 // if we're limiting by items, we can hide items instead of limiting pixel height 1263 if (this.limitUnit === 'item' || this.limitUnit === 'items') { 1264 var items = this.container.children, 1265 hideCount = items.length - this.limit; 1266 1267 // do no truncation if there aren't enough items 1268 if (hideCount <= 0) { 1269 return; 1270 } 1271 1272 for (var i = this.limit; i < items.length; i++) { 1273 items[i].style.display = 'none'; 1274 } 1275 1276 if (hideCount > 0) { 1277 buttonSuffix = ' (' + hideCount + (hideCount > 1 ? ' items' : ' item') + ')'; 1278 } 1279 1280 // if we're not limiting by items, we'll need to calculate a max-height 1281 } else { 1282 this.container.style.overflow = 'hidden'; 1283 switch (this.limitUnit) { 1284 case '%': 1285 normalizedLimit = this.container.clientHeight * this.limit / 100; 1286 break; 1287 1288 case 'em': 1289 // compute font size of element in px and multiply 1290 normalizedLimit = parseFloat(getComputedStyle(this.container).fontSize) * this.limit; 1291 break; 1292 1293 case 'rem': 1294 // compute font size of body in px and multiply 1295 normalizedLimit = parseFloat(getComputedStyle(document.body).fontSize) * this.limit; 1296 break; 1297 1298 case 'vh': 1299 // take a percentage of viewport height 1300 normalizedLimit = document.documentElement.clientHeight * this.limit / 100; 1301 break; 1302 1303 case 'vw': 1304 // take a percentage of viewport width 1305 normalizedLimit = document.documentElement.clientWidth * this.limit / 100; 1306 break; 1307 1308 case 'line': 1309 case 'lines': 1310 // calculate pixel height of a line and multiply 1311 (function (container, limit) { 1312 var testEl = document.createElement('div'), 1313 lineHeight; 1314 testEl.textContent = 'A'; 1315 container.insertBefore(testEl, container.firstElementChild); 1316 lineHeight = testEl.offsetHeight; 1317 container.removeChild(testEl); 1318 normalizedLimit = lineHeight * limit; 1319 })(this.container, this.limit); 1320 break; 1321 1322 default: // px 1323 normalizedLimit = this.limit; 1324 break; 1325 } 1326 1327 this.container.style.maxHeight = normalizedLimit + 'px'; 1328 } 1329 1330 this.button.textContent = this.buttonText + buttonSuffix; 1331 this.container.parentNode.insertBefore(this.button, this.container.nextElementSibling); 1332 1333 this.isTruncated = true; 1334 }, 1335 1336 expand: function () { 1337 if (this.limitUnit === 'item' || this.limitUnit === 'items') { 1338 _.forEach(this.container.children, function (item) { 1339 item.style.display = ''; 1340 }); 1341 } else { 1342 this.container.style.overflow = ''; 1343 this.container.style.maxHeight = ''; 1344 } 1345 1346 if (this.isRepeatable) { 1347 this.button.textContent = this.expandedButtonText; 1348 } else { 1349 this.button.parentNode.removeChild(this.button); 1350 } 1351 1352 this.isTruncated = false;
1353 } 1354 }; 1355 1356 buttonPrototype.setAttribute('type', 'button'); 1357 buttonPrototype.className = 'truncator-expand'; 1358 1359 function add(els, options) { 1360 els = toArray(els); 1361 options = options || {}; 1362 _.forEach(els, function (el) { 1363 var truncator = _.assign(Object.create(truncatorPrototype), {container: el}, options); 1364 truncatorList.push(truncator); 1365 truncator.init(); 1366 }); 1367 } 1368 1369 function init() { 1370 add(document.querySelectorAll('[data-truncate-to]')); 1371 } 1372 1373 return { 1374 init: init, 1375 add: add 1376 }; 1377 })(); 1378 1379 1380 /* =horizontal scrollers 1381 * ===================== 1382 * 1383 * enable non-touch controls for horizontal scrolling sections 1384 * 1385 * <section class="horizontal-scroll"> 1386 * <div class="horizontal-scroll__wrapper"> 1387 * <ul class="horizontal-scroll__contents"> ... </ul> 1388 * </div> 1389 * <button class="horizontal-scroll__control--left"> ... </button> 1390 * <button class="horizontal-scroll__control--right"> ... </button> 1391 * </setion> 1392 */ 1393 1394 var horizontalScrollers = (function () { 1395 var scrollers = [], 1396 1397 scrollerPrototype = { 1398 el: null, 1399 wrapper: null, 1400 contents: null, 1401 leftButton: null, 1402 rightButton: null, 1403 1404 // adjusts how big the 'gravity well' at the start and end of the scrollable area is 1405 gravity: 24, 1406 // when using the scroller arrows, if the remaining area to be scrolled in the direction 1407 // the user just scrolled is less than this value wide (in pixels), it will just scroll 1408 // the rest of the way 1409 pagingTolerance: 180, 1410 // when using the scroller arrows, if we don't end up at an endcap, snap the left side to 1411 // the nearest item edge? 1412 pagingSnap: true, 1413 1414 startClass: 'horizontal-scroll--is-at-start', 1415 endClass: 'horizontal-scroll--is-at-end', 1416 1417 init: function init(container) { 1418 this.el = container; 1419 this.wrapper = container.querySelector('.horizontal-scroll__wrapper'); 1420 this.contents = container.querySelector('.horizontal-scroll__contents'); 1421 this.leftButton = container.querySelector('.horizontal-scroll__control--left'); 1422 this.rightButton = container.querySelector('.horizontal-scroll__control--right'); 1423 1424 if (!this.wrapper || !this.contents) { 1425 throw new Error('Structure of horizontal scroller is not correct'); 1426 } 1427 1428 this.wrapper.addEventListener('scroll', this.scrollListener.bind(this)); 1429 1430 if (this.leftButton) { 1431 this.leftButton.addEventListener('click', this.pageLeft.bind(this)); 1432 } 1433 if (this.rightButton) { 1434 this.rightButton.addEventListener('click', this.pageRight.bind(this)); 1435 } 1436 1437 this.checkPosition(); 1438 }, 1439 1440 scrollListener: (function () { 1441 return _.throttle(function throttledScrollListener() { 1442 this.checkPosition(); 1443 }, 200, { leading: false }); 1444 })(), 1445 1446 checkPosition: function checkPosition() { 1447 // check if we're at either edge of the scrollable area, and apply classes accordingly 1448 var scrollSize = this.wrapper.clientWidth, 1449 contentSize = this.contents.scrollWidth, 1450 leftOffset = this.wrapper.scrollLeft; 1451 1452 this.el.classList.remove(this.startClass); 1453 this.el.classList.remove(this.endClass); 1454 1455 if (leftOffset < this.gravity) { 1456 this.el.classList.add(this.startClass); 1457 this.scrollToStart(); 1458 } else if (leftOffset > contentSize - scrollSize - this.gravity) { 1459 this.el.classList.add(this.endClass); 1460 this.scrollToEnd(); 1461 } 1462 }, 1463 1464 scrollToStart: function scrollToStart() { 1465 this.wrapper.scrollLeft = 0; 1466 }, 1467 1468 scrollToEnd: function scrollToEnd() { 1469 this.wrapper.scrollLeft = this.contents.scrollWidth - this.wrapper.clientWidth; 1470 }, 1471 1472 pageLeft: function pageLeft() { 1473 var pageSize = this.wrapper.clientWidth, 1474 currentOffset = this.wrapper.scrollLeft, 1475 destinationOffset = Math.max(currentOffset - pageSize, 0); 1476 1477 if (destinationOffset < this.pagingTolerance) { 1478 this.scrollToStart(); 1479 } else { 1480 this.wrapper.scrollLeft = this.pagingSnap ? this.snapToNearest(destinationOffset)
1480: destinationOffset; 1481 } 1482 }, 1483 1484 pageRight: function pageRight() { 1485 var pageSize = this.wrapper.clientWidth, 1486 contentSize = this.contents.scrollWidth, 1487 currentOffset = this.wrapper.scrollLeft, 1488 maxOffset = contentSize - pageSize, 1489 destinationOffset = Math.min(currentOffset + pageSize, maxOffset); 1490 1491 if (destinationOffset > maxOffset - this.pagingTolerance) { 1492 this.scrollToEnd(); 1493 } else { 1494 this.wrapper.scrollLeft = this.pagingSnap ? this.snapToNearest(destinationOffset) : destinationOffset; 1495 } 1496 }, 1497 1498 snapToNearest: function snapToNearest(targetOffset) { 1499 var nearestItemOffset = 0, 1500 leftCandidate = 0, 1501 rightCandidate = 0, 1502 leftDiff = 0, 1503 rightDiff = 0, 1504 thisChild = null; 1505 1506 for (var i = 0; i < this.contents.children.length; i++) { 1507 thisChild
1507= this.contents.children[i]; 1508 if (nearestItemOffset + thisChild.clientWidth > targetOffset) { 1509 leftCandidate = nearestItemOffset; 1510 rightCandidate = nearestItemOffset + thisChild.clientWidth; 1511 break; 1512 } 1513 nearestItemOffset += thisChild.clientWidth; 1514 } 1515 1516 leftDiff = targetOffset - leftCandidate; 1517 rightDiff = rightCandidate - targetOffset; 1518 return leftDiff < rightDiff ? leftCandidate : rightCandidate; 1519 } 1520 }; 1521 1522 // public: return a list of scrolling sections tracked by this module 1523 function list() { 1524 return scrollers; 1525 } 1526 1527 // public: add this module's functionality to a container element 1528 // see module comment for expected structure 1529 function add(container) { 1530 var scroller = Object.create(scrollerPrototype); 1531 try { 1532 scroller.init(container); 1533 scrollers.push(scroller); 1534 } catch (e) { 1535 console.error(e); 1536 } 1537 } 1538 1539 // public: initialize this module 1540 function init() { 1541 _.forEach(document.querySelectorAll('.horizontal-scroll'), function (container) { 1542 add(container); 1543 }); 1544 } 1545 1546 return { 1547 add: add, 1548 list: list, 1549 init: init, 1550 }; 1551 }()); 1552 1553 1554 /* =misc 1555 * ===== 1556 * 1557 * miscellaneous enhancements that aren't large enough to justify a module 1558 */ 1559 1560 var misc = (function () { 1561 1562 var exports = Object.create(null); 1563 1564 1565 // mobile menu toggle 1566 exports.addMobileMenuToggle = function addMobileMenuToggle() { 1567 var inactiveClass = 'mobile-menu-was-open'; 1568 toggles.add(document.getElementsByClassName('page-header__toggle'), { 1569 targets: toArray(document.querySelectorAll('body, .page-header__content')), 1570 activeClass: 'mobile-menu-is-open', 1571 deactivateOnEsc: true, 1572 useTargetAria: false, 1573 onActivate: function (toggle) { 1574 // add an inactive class only after the menu has been opened and then closed 1575 if (!toggle.origin.classList.contains(toggle.activeClass)) { 1576 _.forEach([toggle.origin].concat(toggle.targets), function (el) { 1577 el.classList.add(inactiveClass); 1578 }); 1579 } else { 1580 _.forEach([toggle.origin].concat(toggle.targets), function (el) { 1581 el.classList.remove(inactiveClass); 1582 }); 1583 } 1584 } 1585 }); 1586 }; 1587 1588 1589 // subnav toggles for mobile drilldown 1590 exports.addNavDrilldownButtons = function addNavDrilldownButtons() { 1591 var toggle = document.createElement('button'); 1592 toggle.className = 'header-nav__toggle'; 1593 toggle.setAttribute('type', 'button'); 1594 toggle.setAttribute('data-toggle-target', 'next'); 1595 toggle.innerHTML = 'Expand subnavigation for previous item'; 1596 1597 _.forEach(document.querySelectorAll('.header-nav a + ul'), function (sublist) { 1598 sublist.parentNode.insertBefore(toggle.cloneNode(true), sublist); 1599 }); 1600 }; 1601 1602 1603 // allow visual focus and selection indicators of input labels 1604 exports.focusInputLabels = function focusInputLabels() { 1605 _.forEach(document.querySelectorAll('label[for]'), function (label) { 1606 var input = document.getElementById(label.getAttribute('for')); 1607 // track search container for disclosure search only 1608 var searchContainer = null; 1609 if (label.classList.contains('header-search__label')) { 1610 searchContainer = document.querySelector('.header-search'); 1611 } 1612 1613 if (input) { 1614 input.addEventListener('focus', function (ev) { 1615 label.classList.add('is-focused'); 1616 if (searchContainer) { 1617 searchContainer.classList.add('is-focused'); 1618 } 1619 }); 1620 input.addEventListener('blur', function (ev) { 1621 label.classList.remove('is-focused'); 1622 if (searchContainer) { 1623 searchContainer.classList.remove('is-focused'); 1624 } 1625 }); 1626 if (input.type.toLowerCase() === 'radio' || input.type.toLowerCase() === 'checkbox') { 1627 input.addEventListener('change', function (ev) { 1628 if (input.checked) { 1629 label.classList.add('is-selected'); 1630 } else { 1631 label.classList.remove('is-selected'); 1632 } 1633 }); 1634 } 1635 // trigger 'change' event manually in order to populate highlight states on page load 1636 if ("createEvent" in document) { 1637 var ev = document.createEvent("HTMLEvents"); 1638 ev.initEvent("change", false, true); 1639 input.dispatchEvent(ev); 1640 } else { 1641 input.fireEvent("onchange"); 1642 } 1643 } 1644 }); 1645 }; 1646 1647 1648 // add utility classes to form inputs 1649 // this makes dynamic labels possible, for example 1650 exports.addInputUtilityClasses = function addInputUtilityClasses() { 1651 var inputs = toArray(document.getElementsByTagName('input')) 1652 .filter(isTextInput) 1653 .concat(toArray(document.getElementsByTagName('textarea'))); 1654 var currentContentClass = 'has-content'; 1655 var pastContentClass = 'did-have-content'; 1656 1657 _.forEach(inputs, function addClassesToInput(input) { 1658 var hasContainedContent = false;
1659 1660 // initial state 1661 if (input.value) { 1662 input.classList.add(currentContentClass); 1663 hasContainedContent = true; 1664 } 1665 1666 // changes 1667 input.addEventListener('input', function (ev) { 1668 if (input.value) { 1669 hasContainedContent = true; 1670 input.classList.remove(pastContentClass); 1671 input.classList.add(currentContentClass); 1672 } else { 1673 input.classList.remove(currentContentClass); 1674 if (hasContainedContent) { 1675 input.classList.add(pastContentClass); 1676 } 1677 } 1678 }); 1679 }); 1680 }; 1681 1682 var isTextInput = (function () { 1683 var textInputTypes = ['color', 'date', 'email', 'number', 'password', 'search', 'tel', 'text', 'time', 'url']; 1684 return function isTextInput(el) { 1685 return textInputTypes.includes(el.getAttribute('type')); 1686 }; 1687 })(); 1688 1689 1690 // automatically create slideshow tab indicators 1691 exports.createHomeSlideshowTabs = function createHomeSlideshowTabs() { 1692 var tab = document.createElement('button'); 1693 tab.className = 'home-feature__tab tabs__tab'; 1694 tab.setAttribute('type', 'button'); 1695 _.forEach(document.getElementsByClassName('home-feature'), function (feature) { 1696 var nav = feature.querySelector('.home-feature__nav'); 1697 if (!nav) { 1698 nav = document.createElement('div'); 1699 nav.className = 'home-feature__nav'; 1700 feature.appendChild(nav); 1701 } 1702 _.forEach(feature.querySelectorAll('.tabs__pane'), function (pane) { 1703 nav.appendChild(tab.cloneNode(true)); 1704 }); 1705 }); 1706 }; 1707 1708 1709 // combine adjacent tabs modules when placed in Sitefinity 1710 exports.combineTabs = function combineTabs() { 1711 // combine under the first parent 1712 var containers = toArray(document.querySelectorAll('.sf_cols.tabs')), 1713 currentContainer, 1714 currentType, 1715 children; 1716 1717 if (containers.length > 0 && !document.body.classList.contains('sfPageEditor')) { 1718 for (var i = 0; i < containers.length; i++) { 1719 currentContainer = containers[i]; 1720 currentType = currentContainer.className; 1721 while (currentContainer.nextElementSibling && currentContainer.nextElementSibling.className === currentType) { 1722 children = toArray(currentContainer.nextElementSibling.querySelectorAll('.tabs__tab, .tabs__pane')); 1723 while (children.length) { 1724 currentContainer.appendChild(children.shift()); 1725 } 1726 currentContainer.parentNode.removeChild(currentContainer.nextElementSibling); 1727 } 1728 } 1729 } 1730 1731 // reorder and count children 1732 _.forEach(document.querySelectorAll('.sf_cols.tabs'), function (container) { 1733 _.forEach(container.querySelectorAll('.tabs__tab'), function (tab) { 1734 container.appendChild(tab); 1735 }); 1736 _.forEach(container.querySelectorAll('.tabs__pane'), function (pane) { 1737 container.appendChild(pane); 1738 }); 1739 }); 1740 }; 1741 1742 1743 // add indicator to header utility dropdown items 1744 exports.addUtilityDropdownIndicators = function addUtilityDropdownIndicators() { 1745 _.forEach(document.querySelectorAll('.header-utility__item > ul'), function (list) { 1746 list.parentNode.children[0].innerHTML += ' <i class="fas fa-chevron-down"></i>'; 1747 }); 1748 }; 1749 1750 1751 // automatically add arrow indicators to standard accordions 1752 exports.addAccordionIndicators = function addAccordionIndicators() { 1753 var indicator = document.createElement('span'); 1754 indicator.className = 'fas fa-arrow-right'; 1755 1756 _.forEach(document.querySelectorAll('.accordion__label'), function (label) { 1757 var childDivs = label.querySelectorAll('div'); 1758 (childDivs.length > 0 ? childDivs[childDivs.length - 1] : label).appendChild(indicator.cloneNode(true)); 1759 }); 1760 }; 1761 1762 // make certain iframes responsive 1763 (function () { 1764 var sources = ['youtube', 'youtu.be', 'vimeo']; 1765 var wrapperClass = 'embedvid'; 1766 1767 _.forEach(document.querySelectorAll('iframe, object, embed'), function (video) { 1768 if (video.src && sources.reduce(searchSources, false)) { 1769 1770 // some videos may already have been manually wrapped in our helper class 1771 if (!video.parentNode.classList.contains(wrapperClass)) { 1772 1773 // create our wrapper and wrap the video in place 1774 var wrapper = document.createElement('div'); 1775 wrapper.className = wrapperClass; 1776 video.parentNode.insertBefore(wrapper, video); 1777 wrapper.appendChild(video); 1778 1779 // manually calculate our aspect ratio in case it doesn't 1780 // match our 16:9 assumption 1781 if (video.getAttribute('width') && video.getAttribute('height')) { 1782 var width = parseInt(video.getAttribute('width'), 10); 1783 var height = parseInt(video.getAttribute('height'), 10); 1784 wrapper.style.paddingBottom = (height / width * 100) + '%'; 1785 } 1786 } 1787 } 1788 1789 // reducer function to determine whether our video has a whitelisted source 1790 function searchSources(acc, cur) { 1791 return acc ? acc : video.src.indexOf(cur) >= 0; 1792 } 1793 }); 1794 })(); 1795 1796 1797 return exports; 1798 1799 }()); 1800 1801 1802 /* =initialize modules 1803 * =================== 1804 */ 1805 1806 var init = function () { 1807 // functions that must run before large modules 1808 // e.g. functions that output toggles that must then be initialized 1809 misc.combineTabs(); 1810 misc.addNavDrilldownButtons(); 1811 misc.createHomeSlideshowTabs(); 1812 1813 // large modules 1814 toggles.init(); 1815 tabs.init(); 1816 sharedHeights.init(); 1817 waypoints.init(); 1818 truncators.init(); 1819 horizontalScrollers.init();
1820 sharing.init(); 1821 1822 // misc modules 1823 misc.addMobileMenuToggle(); 1824 misc.focusInputLabels(); 1825 misc.addInputUtilityClasses(); 1826 misc.addUtilityDropdownIndicators(); 1827 misc.addAccordionIndicators(); 1828 1829 document.documentElement.classList.remove('no-js'); 1830 document.documentElement.classList.add('js'); 1831 }; 1832 1833 1834 /* =public 1835 * ======= 1836 */ 1837 1838 return { 1839 xhr: xhr, 1840 toggles: toggles, 1841 tabs: tabs, 1842 sharedHeights: sharedHeights, 1843 waypoints: waypoints, 1844 sharing: sharing, 1845 horizontalScrollers: horizontalScrollers, 1846 init: init 1847 }; 1848 1849}(window, document)); 1850 1851RDJS.init(); 1852//# sourceMappingURL=../maps/scripts.js.map
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.