1/** 2 * off-canvas nav 3 * @name ruffnav 4 * @author Dominik Kressler 5 * @version 2.4.3 6 * @thanks Jasmin Reiff 7 * @edit by Malte Zoudlik 8 * @edit by Dario Rieke 9 * @edit by Marc-Kevin Scharlipp 10 * @lastedit 2024-09-30 11 * @copyright (c) 2018 12 */ 13function Ruffnav(options) { 14 this.options = { 15 toggler: '#navi-toggled', 16 container: '#navigation', 17 navList: '.navi-main', 18 subParent: '.has-sub', 19 subClass: '.sub', 20 backText: false, 21 mobileBreak: 'large', 22 closeOnScroll: true, 23 desktopHover: false, 24 navSplit: false, 25 navAppend: false 26 } 27 this.options = { ...this.options, ...options } 28 29 this.depth = 0; 30 this.toggler = document.querySelector(this.options.toggler); 31 this.container = document.querySelector(this.options.container); 32 this.navList = this.container.querySelector(this.options.navList) 33 this.sub = this.container.querySelectorAll(this.options.subClass); 34 this.subParent = this.container.querySelectorAll(this.options.subParent); 35 this.html = document.documentElement; 36 this.device = breakpoint(this.options.mobileBreak) ? 'desktop' : 'mobile'; 37 this.isOpen = false; 38 this.subToggler = null; 39 this.windowSize = window.innerWidth; 40 this.navSize = 0; 41 this.activePath = ''; 42 this.path = window.location.pathname.split('/').filter(function (el) { return el != '' }); 43 this.navigationStatus = document.querySelector('[data-navigation-status]'); 44 this.lang = this.html.lang.split(/[-_]/)[0].toLowerCase(); 45 46 // true if nav is opened first time or reopened 47 var alreadyOpened = false; 48 49 // set back button text by language, or by overwrite 50 if (this.options.backText === false) { 51 this.options.backText = (this.lang == 'de') ? 'Zurück' : 'back'; 52 } 53 54 this.backButton = create('li', { 55 'class': 'nav-back', 56 'tabindex': '0', 57 'aria-label': this.options.backText, 58 'role': 'button' 59 }, '<span>' + this.options.backText + '</span>'); 60 61 var self = this; 62 this.init = function () { 63 if (!self.navList) return console.warn(`No element found with class "${self.options.navList}". Ruffnav could't be initialized. Please add the class to your main navigation UL-Element`); 64 // add class for desktop hover 65 self.container.classList.add('first-level'); 66 if (self.options.desktopHover) self.container.classList.add('hover-navigation'); 67 68 const navigationName = (self.lang == 'de') ? `Hauptnavigation` : `main navigation` 69 self.container.firstElementChild.setAttribute('aria-label', navigationName); 70 71 // remove aria-expanded attribute on desktop 72 const naviUl = self.navList.querySelector(':scope > .navi'); 73 let deviceType = breakpoint(self.options.mobileBreak) ? 'desktop' : 'mobile'; 74 75 // remove expanded attribute for desktop navigation 76 if (deviceType === 'desktop') { 77 self.container.removeAttribute('aria-expanded'); 78 } 79 80 // append custom Content to Navigation 81 if (self.options.navAppend) { 82 var itemToAppend = create('li', { 'class': 'nav-item appended-item' }, self.options.navAppend); 83 self.navList.appendChild(itemToAppend); 84 } 85 86 self.navSize = self.navList.childNodes.length; 87 // split navigation in half 88 89 // transform Navigation markup 90 [].forEach.call(self.subParent, function (parentItem) { 91 var childNode = parentItem.firstElementChild, 92 categoryLabel = (childNode.dataset && childNode.dataset.categoryLabel) ? childNode.dataset.categoryLabel : childNode.innerHTML.replace(/(<([^>]+)>)/gi, ""); 93 94 // parent-name attribute for UL's 95 let subParentName = childNode.textContent; 96 subParentName = subParentName.replace(/(\r\n|\n|\r)/gm, ''); 97 childNode.nextElementSibling.setAttribute('data-parent-name', subParentName.trim()); 98 99 // only clone when Element is a link 100 if (childNode.nodeName !== 'SPAN') { 101 let activeLinkClass = (childNode.classList.contains('active') && window.location.origin+window.location.pathname === childNode.href) ? 'active' : ''; 102 103 // copy link inside UL's 104 childNode.nextElementSibling.insertBefore(create('li', { 105 'class': 'nav-item ' + childNode.className.trim() 106 }, '<a href="' + childNode.href + '" title="' + childNode.title + '" class="'+ activeLinkClass +'">' + categoryLabel + '</a>'), parentItem.children[1].children[0]); 107 if (self.path.length) { 108 self.activeClassFallback(childNode); 109 } 110 } 111 112 const subTogglerLabel = (self.lang == 'de') ? `${categoryLabel} Unterverzeichnis öffnen` : `open ${categoryLabel} sub-navigation`; 113 // replace link's with span's 114 parentItem.replaceChild(create('span', { 115 'class': 'sub-toggler ' + childNode.className.trim(), 116 'tabindex': 0, 117 'data-href': childNode.href, 118 'aria-haspopup': 'true', 119 'aria-expanded': 'false', 120 'aria-label': subTogglerLabel, 121 'role': 'button' 122 }, childNode.innerHTML), childNode) 123 }); 124 125 // add backbuttons for all submenus 126 [].forEach.call(self.sub, function (subItem) { 127 subItem.setAttribute('aria-expanded', 'false'); 128 const subMenuLabel = (self.lang == 'de') ? `${subItem.dataset.parentName} Unterverzeichnis` : `${subItem.dataset.parentName} sub-navigation`; 129 subItem.setAttribute('aria-label', subMenuLabel); 130 131 // add tabindex for mobile navigation to make submenu focusable 132 if (deviceType === 'desktop') { 133 subItem.removeAttribute('tabindex'); 134 } else if (deviceType === 'mobile') { 135 subItem.setAttribute('tabindex', '0'); 136 } 137 138 // add backbuttons to UL's and add aria-label 139 const thisBackButton = self.backButton.cloneNode(true); 140 const backButtonLabel = (self.lang == 'de') ? `${subItem.dataset.parentName}
140 Unterverzeichnis schlieÃen` : `close ${subItem.dataset.parentName} sub-navigation`; 141 thisBackButton.setAttribute('aria-label', backButtonLabel); 142 subItem.insertBefore(thisBackButton, subItem.childNodes[0]); 143 }); 144 145 // navigation split 146 if (self.options.navSplit) { 147 var elm; 148 var splitList = create('ul', { class: 'navi split-list' }); 149 for (var i = parseInt(self.navSize / 2); i != splitList.childNodes.length; i) { 150 elm = self.navList.childNodes[i].cloneNode(true); 151 splitList.appendChild(elm); 152 self.navList.childNodes[i].remove(); 153 } 154 self.navList.parentElement.appendChild(splitList); 155 } 156 157 // add aria attributes to each link inside navigation 158 let allNavigationLinks = self.navList.querySelectorAll('a'); 159 allNavigationLinks.forEach(naviLink => { 160 // update / set aria-attributes for current location 161 if(naviLink.classList.contains('active')) { 162 let naviLinkName = naviLink.textContent; 163 naviLinkName = naviLinkName.replace(/(\r\n|\n|\r)/gm, ''); 164 const activePageLabel = (self.lang == 'de') ? `${naviLinkName} (aktuelle Seite)` : `${naviLinkName} (current location)`; 165 naviLink.setAttribute('aria-label', activePageLabel); 166 } else if(naviLink.getAttribute('target') === '_blank') { 167 let naviLinkName = naviLink.textContent; 168 naviLinkName = naviLinkName.replace(/(\r\n|\n|\r)/gm, ''); 169 const externPageLabel = (self.lang == 'de') ? `${naviLinkName} (öffnet eine neue Seite)` : `${naviLinkName} (opens a new window)`; 170 naviLink.setAttribute('aria-label', externPageLabel); 171 } 172 }); 173 174 // close on mobile toggle 175 self.toggler.addEventListener('click', self._closeAll); 176 // add Listeners for different events 177 self.addListener(); 178 // add global resize listener 179 self.listenResize(); 180 181 return self; 182 } 183 this._open = function (e) { 184 // open event 185 var target, sameLevelCurrents; 186 187 if (e.type === 'mouseenter' && self.device === 'mobile') return; 188 if (e.type === 'mouseenter') { 189 target = e.target; 190 target.childNodes[1].classList.add('current'); 191 target.childNodes[1].setAttribute('aria-expanded', 'true'); 192 if (self.container.querySelector('.open') != null) self.container.querySelector('.open').classList.remove('open'); 193 target.childNodes[1].classList.add('open'); 194 195 if (self.options.desktopHover) { 196 target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'true'); 197 target.querySelector(':scope > .sub').classList.add('current'); 198 } 199 } else { 200 target = e.currentTarget; 201 202 //close other on same level when already opened 203 sameLevelCurrents = target.parentElement.parentElement.querySelector('.current'); 204 if (sameLevelCurrents !== null) self.closeRecusive(sameLevelCurrents, false); 205 206 target.nextElementSibling.classList.add('current'); 207 target.setAttribute('aria-expanded', 'true'); 208 target.nextElementSibling.setAttribute('aria-expanded', 'true'); 209 if (self.container.querySelector('.open') != null) self.container.querySelector('.open').classList.remove('open'); 210 target.nextElementSibling.classList.add('open'); 211 212 // inform screenreader via aria-live that submenu got opened 213 const currentSubName = target.nextElementSibling.getAttribute('aria-label'); 214 const subStatusOpened = (self.lang == 'de') ? `${currentSubName} wurde geöffnet` : `${currentSubName} got opened`; 215 updateNavigationStatus(''); 216 updateNavigationStatus(subStatusOpened); 217 218 // set focus on submenu 219 setTimeout(() => { 220 target.nextElementSibling.focus() 221 }, '250'); 222 223 // oberserve focus inside submenu 224 document.addEventListener('keydown', initWatchFocus); 225 } 226 if (!self.isOpen && self.device !== 'mobile') { 227 if (self.options.closeOnScroll) window.addEventListener('scroll', self._closeAll); 228 document.addEventListener('click', self._closeAll); 229 // document.addEventListener('keydown', self._closeAll); 230 self.isOpen = true; 231 } 232 self.container.classList.remove('first-level'); 233 self.scrollTop(); 234 } 235 this._close = function (e) { 236 // close event 237 var currentListTree; 238 if (e.type === 'mouseleave' && self.device === 'mobile') return; 239 if (e.type === 'mouseleave') currentListTree = e.currentTarget; 240 else currentListTree = e.currentTarget.parentElement; 241 242 self.closeRecusive(currentListTree, false); 243 if (self.container.querySelectorAll('ul.current').length === 0) self.isOpen = false;
244 if (self.container.querySelectorAll('ul.current').length === 0) self.container.classList.add('first-level'); 245 246 // inform screenreader via aria-live that submenu got closed 247 const currentSubName = currentListTree.getAttribute('aria-label'); 248 const subStatusClosed = (self.lang == 'de') ? `${currentSubName} wurde geschlossen` : `${currentSubName} got closed`; 249 updateNavigationStatus(''); 250 updateNavigationStatus(subStatusClosed); 251 252 // set focus for mobile devices 253 const thisSubToggler = currentListTree.parentElement.firstElementChild; 254 thisSubToggler.focus(); 255 256 target = e.currentTarget; 257 if (self.options.desktopHover && e.type === 'mouseleave') { 258 target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'false'); 259 } 260 261 self.scrollTop(); 262 } 263 this._closeAll = function (e) { 264 // check for toggle input and update attributes 265 (self.toggler.checked) ? self.html.classList.add('hidden-scroll') : self.html.classList.remove('hidden-scroll'); 266 (self.toggler.checked) ? self.container.setAttribute('aria-expanded', 'true') : self.container.setAttribute('aria-expanded', 'false'); 267 (self.toggler.checked) ? '' : alreadyOpened = false; 268 const toggleLabel = document.querySelector('label[for="navi-toggled"] [role="button"]'); 269 const menuOpenTogglerLabel = (self.lang == 'de') ? `Hauptnavigation öffnen` : `open main navigation`; 270 const menuCloseTogglerLabel = (self.lang == 'de') ? `Hauptnavigation schlieÃen` : `close main navigation`; 271 (self.toggler.checked) ? toggleLabel.setAttribute('aria-label', menuCloseTogglerLabel) : toggleLabel.setAttribute('aria-label', menuOpenTogglerLabel); 272 (self.toggler.checked) ? toggleLabel.setAttribute('aria-expanded', 'true') : toggleLabel.setAttribute('aria-expanded', 'false'); 273 274 // inform screenreader via aria-live if navigation got closed or opened on mobile 275 if(self.device === 'mobile') { 276 const navigationStatusOpened = (self.lang == 'de') ? `Hauptnavigation wurde geöffnet` : `Main navigation got opened`; 277 const navigationStatusClosed = (self.lang == 'de') ? `Hauptnavigation wurde geschlossen` : `Main navigation got closed`; 278 updateNavigationStatus(''); 279 (self.toggler.checked) ? updateNavigationStatus(navigationStatusOpened) : updateNavigationStatus(navigationStatusClosed); 280 } 281 282 // close all 283 if (e.type === 'scroll') { 284 self.closeRecusive(null, true); 285 } else if (e.type === 'click') { 286 // check if click was not inside navigation 287 if (e.target === self.container || self.container.contains(e.target)) return; 288 else self.closeRecusive(null, true) 289 } else if (e.type === 'keydown') { 290 // check if "enter" was pressed 291 if (e.keyCode === 27) self.closeRecusive(null, true); 292 } else { 293 self.closeRecusive(null, true); 294 } 295 296 self.removeListeners(); 297 self.isOpen = false; 298 } 299 this.closeRecusive = function (firstList, closeAll) { 300 // close all open items 301 if (closeAll) { 302 [].forEach.call(self.container.querySelectorAll('.current'), function (openSubs) { 303 openSubs.classList.remove('current'); 304 openSubs.setAttribute('aria-expanded', 'false'); 305 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 306 }); 307 if (self.container.querySelector('.open') != null) self.container.querySelector('.open').classList.remove('open'); 308 309 self.container.classList.add('first-level'); 310 } else { 311 if (self.container.querySelector('.open') != null) self.container.querySelector('.open').classList.remove('open'); 312 313 [].forEach.call(firstList.querySelectorAll('.current'), function (openSubs) { 314 openSubs.classList.remove('current'); 315 openSubs.setAttribute('aria-expanded', 'false'); 316 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 317 318 // stop observing focus inside ul 319 if(self.device === 'mobile') { 320 document.removeEventListener('keydown', initWatchFocus); 321 } 322 }); 323 324 // update classes and attributes 325 firstList.parentNode.parentNode.classList.add('open'); 326 firstList.classList.remove('current'); 327 firstList.setAttribute('aria-expanded', 'false'); 328 firstList.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 329 } 330 331 // if navigation is opened initially or reopened, set focus and oberserve focus inside submenu 332 if(self.toggler.checked && self.container.querySelectorAll('ul.current').length === 0 && self.device == 'mobile' && !alreadyOpened) { 333 const naviUl = self.navList.querySelector('ul.navi'); 334 335 if(naviUl) { 336 naviUl.classList.add('open'); 337 } else if(self.navList.classList.contains('navi')) { 338 self.navList.classList.add('open'); 339 } 340 341 setTimeout(() => { 342 // oberserve focus inside submenu 343 document.addEventListener('keydown', initWatchFocus); 344 }, '300'); 345 346 alreadyOpened = true; 347 } 348 } 349 this.removeListeners = function () { 350 // remove eventListener for global close 351 window.removeEventListener('scroll', self._closeAll); 352 document.removeEventListener('keydown', self._closeAll); 353 document.removeEventListener('click', self._closeAll); 354 document.removeEventListener('keydown', initWatchFocus); 355 } 356 this.addListener = function () { 357 // add eventListener to sub-togglers and back-buttons 358 self.subToggler = self.container.querySelectorAll('.sub-toggler'); 359 self.backButtons = self.container.querySelectorAll('.nav-back'); 360 [].forEach.call(self.subToggler, function (subTogglerItem) { 361 if (self.options.desktopHover) { 362 subTogglerItem.parentNode.addEventListener('mouseenter', self._open); 363 subTogglerItem.parentNode.addEventListener('mouseleave', self._close); 364 } 365 subTogglerItem.addEventListener('click', self._open); 366 }); 367 [].forEach.call(self.backButtons, function (backButtonsItem) { 368 backButtonsItem.addEventListener('click', self._close, false); 369 }); 370 } 371 this.listenResize = function () { 372 // global resize Event 373 window.onresize = function () { 374 self.device = breakpoint(self.options.mobileBreak) ? 'desktop' : 'mobile'; 375 if (self.device === 'mobile') { 376 self.removeListeners() 377 // update aria-attributes 378 self.container.setAttribute('aria-expanded', 'false'); 379 380 // add tabindex for all submenus to make them focusable 381 [].forEach.call(self.sub, function (subItem) { 382 subItem.setAttribute('tabindex', '0'); 383 }); 384 } 385 if (self.windowSize != window.innerWidth && self.device === 'desktop') { 386 self.closeRecusive(null, true); 387 self.isOpen = false;
388 // update aria-attributes 389 self.container.removeAttribute('aria-expanded'); 390 391 // stop observing focus inside ul 392 document.removeEventListener('keydown', initWatchFocus); 393 394 if(self.toggler.checked) { 395 self.toggler.checked = false; 396 } 397 398 [].forEach.call(self.sub, function (subItem) { 399 subItem.removeAttribute('tabindex'); 400 }); 401 } 402 self.windowSize = window.innerWidth; 403 } 404 } 405 406 this.scrollTop = function () { 407 [].forEach.call(self.sub, function (item) { item.scrollTop = 0 }) 408 self.container.childNodes[0].scrollTop = 0; 409 } 410 411 function create(type, attr, content) { 412 // create Node element 413 var elm = document.createElement(type); 414 [].forEach.call(Object.entries(attr), function (name) { 415 elm.setAttribute(name[0], name[1]); 416 }); 417 if (content) elm.innerHTML = content; 418 return elm; 419 } 420 421 this.activeClassFallback = function (elem) { 422 if (elem.href === window.location.origin + '/' + this.activePath + this.path[0] + '/') { 423 elem.classList.add('active'); 424 if (elem.parentNode.classList.contains(self.options.subParent.substring(1))) elem.parentNode.classList.add('active') 425 if (this.path.length === 1) elem.parentNode.querySelector('[href="' + window.location.origin + '/' + this.activePath + this.path[0] + '/"]').classList.add('active'); 426 this.activePath += this.path[0] + '/'; 427 this.path.splice(0, 1); 428 } 429 } 430 431 // function to update text inside aria-live element for screenreader 432 function updateNavigationStatus(text) { 433 const navigationStatusElement = self.navigationStatus; 434 navigationStatusElement.textContent = text; 435 } 436 437 // call function to observe focus 438 function initWatchFocus(e) { 439 return watchFocus(e, self.container.querySelector('.open')); 440 } 441 442 // observe focus inside ul function 443 function watchFocus(e, modalElement) { 444 const isTabPressed = e.key === 'Tab' || e.keyCode === 9; 445 const isEscPressed = e.key === 'Escape' || e.keyCode === 27; 446 447 if (!isTabPressed && !isEscPressed) return; 448 449 let focusableElements = `:scope > li > [href], :scope > li[role="button"], :scope > li > span[role="button"], :scope > li > span`; 450 let modal = modalElement; 451 if(modal.classList.contains('navi')) { 452 modal = document.getElementById('navigation'); 453 focusableElements = `.navi > li > [href], .navi > li[role="button"], .navi > li > span[role="button"], .navi > li > span, button, a.branding, .phone, .email, .mobile-close-button`; 454 } 455 456 // get focusable and visible elements in modal 457 let visibleElements = []; 458 const focusableContent = modal.querySelectorAll(focusableElements); 459 460 focusableContent.forEach(thisElement => { 461 if(thisElement.checkVisibility()) { 462 visibleElements.push(thisElement); 463 } 464 }); 465 466 const firstFocusableElement = visibleElements[0]; 467 const lastFocusableElement = visibleElements[visibleElements.length - 1]; 468 469 if (isTabPressed) { 470 if (e.shiftKey) { 471 if (document.activeElement === firstFocusableElement) { 472 // if first element is focused and shift + tab is clicked, close menu 473 e.preventDefault(); 474 focusCloseAction(e, modal); 475 } else if (document.activeElement === modal) { 476 // if menu is focused and shift + tab is clicked, close focused menu 477 e.preventDefault(); 478 focusCloseAction(e, modal); 479 } 480 } else if (document.activeElement === lastFocusableElement) { 481 // if last element is focused and tab is clicked, close menu 482 e.preventDefault(); 483 focusCloseAction(e, modal); 484 } 485 } else if (isEscPressed) { 486 // if Esc is clicked, close current menu 487 e.preventDefault(); 488 focusCloseAction(e, modal); 489 } 490 } 491 492 function focusCloseAction(e, modal) { 493 if (modal === document.getElementById('navigation')) { 494 // if first-level, close navigation and set focus to burger icon 495 self.toggler.checked = false;
496 self._closeAll(e); 497 const toggleLabel = document.querySelector('label[for="navi-toggled"] [role="button"]'); 498 toggleLabel.focus(); 499 } else { 500 // if not first-level, toggle nav-back and close submenu 501 modal.querySelector(':scope > .nav-back').click(); 502 } 503 } 504 505 this.init(); 506} 507if (!Object.entries) 508 Object.entries = function (obj) { 509 var ownProps = Object.keys(obj), 510 i = ownProps.length, 511 resArray = new Array(i); // preallocate the Array 512 while (i--) 513 resArray[i] = [ownProps[i], obj[ownProps[i]]]; 514 515 return resArray; 516 }; 517if (!('remove' in Element.prototype)) { 518 Element.prototype.remove = function () { 519 if (this.parentNode) this.parentNode.removeChild(this); 520 }; 521}
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.