1/** 2 * emailmonday Megamenu - behaviour 3 * 4 * Mirrors the interaction model on kinsta.com: 5 * - hover-intent open (short delay in, longer delay out, so diagonal mouse 6 * travel toward the panel does not close it) 7 * - only one panel open at a time; moving between top-level items swaps 8 * panels instantly rather than replaying the open animation 9 * - click/tap toggles (touch devices never get a hover event) 10 * - Escape closes and returns focus to the trigger 11 * - full keyboard support: arrows along the bar, Down opens a panel 12 * - panels flip to right-aligned if they would overflow the viewport 13 */ 14(function () { 15 'use strict'; 16 17 var OPEN_DELAY = 90; 18 var CLOSE_DELAY = 220; 19 var MOBILE_QUERY = '(max-width: 1024px)'; 20 21 function ready(fn) { 22 if (document.readyState !== 'loading') { 23 fn(); 24 } else { 25 document.addEventListener('DOMContentLoaded', fn); 26 } 27 } 28 29 function EmMegaMenu(root) { 30 this.root = root; 31 this.items = Array.prototype.slice.call( 32 root.querySelectorAll('.em-mega__item--has-panel') 33 ); 34 this.triggers = Array.prototype.slice.call( 35 root.querySelectorAll('.em-mega__list > .em-mega__item > .em-mega__top') 36 ); 37 this.openItem = null; 38 this.openTimer = null; 39 this.closeTimer = null; 40 this.mq = window.matchMedia(MOBILE_QUERY); 41 42 this.onDocPointer = this.onDocPointer.bind(this); 43 this.onKeydown = this.onKeydown.bind(this); 44 this.onFocusIn = this.onFocusIn.bind(this); 45 46 this.init(); 47 } 48 49 EmMegaMenu.prototype.isMobile = function () { 50 return this.mq.matches; 51 }; 52 53 EmMegaMenu.prototype.init = function () { 54 var self = this; 55 56 this.items.forEach(function (item) { 57 var trigger = item.querySelector(':scope > .em-mega__top'); 58 var panel = item.querySelector(':scope > .em-mega__panel'); 59 if (!trigger || !panel) { 60 return; 61 } 62 63 // Pointer: hover intent. Skipped entirely on coarse pointers so a tap 64 // does not both hover-open and click-close in the same gesture. 65 item.addEventListener('mouseenter', function (e) { 66 if (self.isMobile() || e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents) { 67 return; 68 } 69 self.scheduleOpen(item); 70 }); 71 72 item.addEventListener('mouseleave', function () { 73 if (self.isMobile()) { 74 return; 75 } 76 self.scheduleClose(); 77 }); 78 79 trigger.addEventListener('click', function (e) { 80 e.preventDefault(); 81 clearTimeout(self.openTimer); 82 clearTimeout(self.closeTimer); 83 if (self.openItem === item) { 84 self.close(); 85 } else { 86 self.open(item); 87 } 88 }); 89 90 // Keep the panel open while the pointer is inside it. 91 panel.addEventListener('mouseenter', function () { 92 clearTimeout(self.closeTimer); 93 }); 94 panel.addEventListener('mouseleave', function () { 95 if (!self.isMobile()) { 96 self.scheduleClose(); 97 } 98 }); 99 }); 100 101 document.addEventListener('keydown', this.onKeydown); 102 document.addEventListener('pointerdown', this.onDocPointer); 103 document.addEventListener('focusin', this.onFocusIn); 104 105 // Close when the viewport changes mode, so state never gets stranded. 106 var onModeChange = function () { self.close(true); }; 107 if (typeof this.mq.addEventListener === 'function') { 108 this.mq.addEventListener('change', onModeChange); 109 } else if (typeof this.mq.addListener === 'function') { 110 this.mq.addListener(onModeChange); 111 } 112 113 window.addEventListener('resize', function () { 114 if (self.openItem) { 115 self.position(self.openItem); 116 } 117 }); 118 }; 119 120 EmMegaMenu.prototype.scheduleOpen = function (item) { 121 var self = this; 122 clearTimeout(this.closeTimer); 123 clearTimeout(this.openTimer); 124 125 // A panel is already open: swap immediately, no re-animation delay. 126 if (this.openItem && this.openItem !== item) { 127 this.open(item); 128 return; 129 } 130 if (this.openItem === item) { 131 return; 132 } 133 this.openTimer = setTimeout(function () { 134 self.open(item); 135 }, OPEN_DELAY); 136 }; 137 138 EmMegaMenu.prototype.scheduleClose = function () { 139 var self = this; 140 clearTimeout(this.openTimer); 141 clearTimeout(this.closeTimer); 142 this.closeTimer = setTimeout(function () { 143 self.close(); 144 }, CLOSE_DELAY); 145 }; 146 147 EmMegaMenu.prototype.position = function (item) { 148 if (this.isMobile()) { 149 return; 150 } 151 var panel = item.querySelector(':scope > .em-mega__panel'); 152 if (!panel) { 153 return; 154 } 155 156 // Measure against the nav's left edge, then flip if we would spill out. 157 item.removeAttribute('data-em-align'); 158 var navLeft = this.root.getBoundingClientRect().left; 159 var width = panel.offsetWidth; 160 if (navLeft + width > window.innerWidth - 20) { 161 item.setAttribute('data-em-align', 'right'); 162 } 163 }; 164 165 EmMegaMenu.prototype.open = function (item) { 166 if (this.openItem && this.openItem !== item) { 167 this.setState(this.openItem, false); 168 } 169 this.openItem = item; 170 this.position(item); 171 this.setState(item, true); 172 173 // Always flagged, even with the backdrop switched off: the header bar 174 // watches this class to stay pinned while a panel is open. 175 if (!this.isMobile()) { 176 document.body.classList.add('em-mega-open'); 177 } 178 }; 179 180 EmMegaMenu.prototype.close = function (immediate) { 181 clearTimeout(this.openTimer); 182 clearTimeout(this.closeTimer); 183 if (this.openItem) { 184 this.setState(this.openItem, false); 185 this.openItem = null; 186 } 187 document.body.classList.remove('em-mega-open'); 188 if (immediate) {
189 this.items.forEach(function (item) { 190 item.removeAttribute('data-em-align'); 191 }); 192 } 193 }; 194 195 EmMegaMenu.prototype.setState = function (item, isOpen) { 196 var trigger = item.querySelector(':scope > .em-mega__top'); 197 item.classList.toggle('is-open', isOpen); 198 if (trigger) { 199 trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 200 } 201 }; 202 203 EmMegaMenu.prototype.onDocPointer = function (e) { 204 if (!this.openItem) { 205 return; 206 } 207 if (!this.root.contains(e.target)) { 208 this.close(); 209 } 210 }; 211 212 EmMegaMenu.prototype.onFocusIn = function (e) { 213 if (!this.openItem) { 214 return; 215 } 216 // Tabbing out of the nav entirely closes the panel. 217 if (!this.root.contains(e.target)) { 218 this.close(); 219 } 220 }; 221 222 EmMegaMenu.prototype.onKeydown = function (e) { 223 if (e.key === 'Escape' && this.openItem) { 224 var trigger = this.openItem.querySelector(':scope > .em-mega__top'); 225 this.close(); 226 if (trigger) { 227 trigger.focus(); 228 } 229 return; 230 } 231 232 if (!this.root.contains(e.target)) { 233 return; 234 } 235 236 var idx = this.triggers.indexOf(e.target); 237 238 if (e.key === 'ArrowDown' && idx > -1) { 239 var item = e.target.closest('.em-mega__item--has-panel'); 240 if (item) { 241 e.preventDefault(); 242 this.open(item); 243 var first = item.querySelector('.em-mega__link'); 244 if (first) { 245 first.focus(); 246 } 247 } 248 return; 249 } 250 251 if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && idx > -1) { 252 e.preventDefault(); 253 var step = e.key === 'ArrowRight' ? 1 : -1; 254 var next = (idx + step + this.triggers.length) % this.triggers.length; 255 this.triggers[next].focus(); 256 } 257 }; 258 259 ready(function () { 260 var roots = document.querySelectorAll('.em-mega'); 261 if (!roots.length) { 262 return; 263 } 264 265 Array.prototype.forEach.call(roots, function (root) { 266 if (root.dataset.emInit) { 267 return; 268 } 269 root.dataset.emInit = '1'; 270 271 if (root.classList.contains('em-mega--no-backdrop')) { 272 document.body.classList.add('em-mega-hide-backdrop'); 273 } 274 275 new EmMegaMenu(root); 276 }); 277 278 if (!document.querySelector('.em-mega-backdrop')) { 279 var backdrop = document.createElement('div'); 280 backdrop.className = 'em-mega-backdrop'; 281 backdrop.setAttribute('aria-hidden', 'true'); 282 document.body.appendChild(backdrop); 283 } 284 }); 285})();
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.