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