1// public/js/megamenu/megamenu-drawer.js 2// 3// Drawer mobile del megamenú. Gestiona: 4// - Apertura/cierre del drawer con hamburger 5// - Stack de paneles L0 (raÃz) â L1 (subcategorÃa) â L2 (productos) 6// - Lazy-mount de los paneles L2: viajan como <template> inerte en el HTML 7// (sin coste de style/layout en el tap) y se montan al entrar en su L1 8// - Scroll lock del body con position:fixed (preserva sticky header) 9// - Focus management (trap dentro del drawer activo) 10// - Escape: cierra LâL o cierra drawer entero según depth 11// 12// Portado de entrega-marcos/public/scripts/cat-drawer.js. Diferencia con 13// el original: el scroll-lock del body se aplica con estilos INLINE (el 14// original usaba reglas CSS sobre body--drawer-open; aquà todo el estilado 15// es Tailwind utilities). Las clases body--drawer-open / body--lock-stuck 16// se mantienen como flags que las variantes Tailwind del header leen. 17// 18// Los clicks del drawer van DELEGADOS en #cat-drawer (no por elemento): los 19// paneles L2 se montan en diferido y un listener atado en el init no los 20// verÃa. ANIM_MS debe coincidir con la duración de las transiciones CSS de 21// los paneles (MegaMenuHeader.astro / MegaMenuDrawerDynamic.astro). 22(function () { 23 var drawer = document.getElementById('cat-drawer'); 24 if (!drawer) return; 25 26 var openBtn = document.querySelector('[data-drawer-open]'); 27 var l0 = drawer.querySelector('.cat-drawer__panel--l0'); 28 var ANIM_MS = 250; 29 30 // Stack del drawer mobile. Cada nivel es un panel que se anima horizontal. 31 // depth 0 â L0 (menú raÃz) visible 32 // depth 1 â L1 (selector zonas) visible, L0 hacia la izquierda 33 // depth 2 â L2 (productos zona) visible, L1 hacia la izquierda 34 // 35 // Los L2 incluyen `parent` (el id del L1 que lo abrió) porque varios L1
36 // distintos pueden tener L2s con el mismo slug â ej. "face" existe en 37 // L1 zonas (productos) y L1 soluciones (problemas). 38 var stack = []; // ej. [{type:'l1', id:'zonas'}, {type:'l2', id:'face', parent:'zonas'}] 39 40 var lastFocused = null; 41 var savedScrollY = 0; 42 // Timer pendiente de close() (pone hidden=true al acabar la animación). Se 43 // guarda para que open() pueda CANCELARLO: si no, reabrir durante el cierre 44 // acababa en is-open + hidden â menú invisible con el scroll bloqueado 45 // ("menú fantasma") que costaba dos taps extra recuperar. 46 var closeTimer = null; 47 var idleHandle = null; 48 49 /* Scroll-lock inline (ver nota de cabecera). */ 50 function lockBodyScroll(scrollY) { 51 var s = document.body.style; 52 s.top = '-' + scrollY + 'px'; 53 s.position = 'fixed'; 54 s.left = '0'; 55 s.right = '0'; 56 s.width = '100%'; 57 s.overflow = 'hidden'; 58 s.scrollbarGutter = 'stable'; 59 } 60 function unlockBodyScroll() { 61 var s = document.body.style; 62 s.top = ''; 63 s.position = ''; 64 s.left = ''; 65 s.right = ''; 66 s.width = ''; 67 s.overflow = ''; 68 s.scrollbarGutter = ''; 69 } 70 71 function activePanel() { 72 if (stack.length === 0) return l0; 73 var top = stack[stack.length - 1]; 74 if (top.type === 'l1') { 75 return drawer.querySelector('.cat-drawer__panel--l1[data-l1="' + top.id + '"]'); 76 } 77 return drawer.querySelector( 78 '.cat-drawer__panel--l2[data-l2="' + top.id + '"][data-l1-parent="' + top.parent + '"]' 79 ); 80 } 81 82 function getFocusable(scope) { 83 return Array.prototype.slice.call( 84 (scope || activePanel()).querySelectorAll( 85 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' 86 ) 87 ).filter(function (el) { return el.offsetParent !== null; }); 88 } 89 90 // Sync visibilidad/aria de paneles según el stack actual. Los paneles 91 // viven todos en el DOM y se mueven con transform según data-state 92 // (las variantes Tailwind data-[state=...] los colocan): 93 // 'active' â translateX(0) 'behind' â -30% 'ahead' â 100% 94 function syncPanels() { 95 var depth = stack.length; 96 setPanelState(l0, depth === 0 ? 'active' : 'behind'); 97 drawer.querySelectorAll('.cat-drawer__panel--l1').forEach(function (p) { 98 var id = p.getAttribute('data-l1'); 99 if (depth === 0) return setPanelState(p, 'ahead'); 100 var first = stack[0]; 101 if (first.type !== 'l1' || first.id !== id) return setPanelState(p, 'ahead'); 102 setPanelState(p, depth === 1 ? 'active' : 'behind'); 103 }); 104 // L2 panels: active si el top es l2 con ese id Y ese parent. 105 drawer.querySelectorAll('.cat-drawer__panel--l2').forEach(function (p) { 106 var id = p.getAttribute('data-l2'); 107 var parent = p.getAttribute('data-l1-parent'); 108 var top = stack[stack.length - 1]; 109 if (!top || top.type !== 'l2' || top.id !== id || top.parent !== parent) { 110 return setPanelState(p, 'ahead'); 111 } 112 setPanelState(p, 'active'); 113 }); 114 } 115 116 function setPanelState(panel, state) { 117 if (!panel) return; 118 panel.setAttribute('data-state', state); 119 panel.setAttribute('aria-hidden', state === 'active' ? 'false' : 'true'); 120 } 121 122 /* ââ Lazy-mount de paneles L2 ââââââââââââââââââââââââââââââââââââââââââââ 123 Los L2 (93% de los nodos del drawer) viajan como 124 <template data-l2-template data-l1-parent> â inertes: no participan en 125 style/layout ni descargan imágenes, asà el tap del burger solo layoutea 126 L0 + L1s. El contrato de la animación push-panel es que el panel DESTINO 127 exista al menos un frame antes de activarse: montamos los hijos de un L1 128 al ENTRAR en él (sÃncrono, cubierto por la animación L0âL1) y el resto 129 en idle tras abrir el drawer. Montar = MOVER el fragment al DOM (una 130 sola vez; el template se elimina â idempotente). */ 131 function mountL2Panels(l1Id) { 132 var tpls = drawer.querySelectorAll('template[data-l2-template]'); 133 var mountedPanels = []; 134 for (var i = 0; i < tpls.length; i++) { 135 var tpl = tpls[i]; 136 if (l1Id && tpl.getAttribute('data-l1-parent') !== l1Id) continue; 137 var panel = tpl.content.firstElementChild; 138 tpl.parentNode.insertBefore(tpl.content, tpl); 139 tpl.parentNode.removeChild(tpl); 140 if (panel) mountedPanels.push(panel); 141 } 142 if (!mountedPanels.length) return; 143 // cart.js vincula los .add-to-cart por elemento UNA vez en DOMContentLoaded 144 // â estos paneles no existÃan entonces. Vincular EN ESTE MISMO tick (el 145 // usuario puede tocar la bolsa en cuanto el panel es visible), scoped al 146 // panel recién montado: nunca re-lanzar global (el cloneNode de cart.js 147 // dejarÃa huérfano cualquier botón con un add en vuelo, p.ej. el de la PDP). 148 if (window.cart && typeof window.cart.initializeAddToCartButtons === 'function') { 149 for (var p = 0; p < mountedPanels.length; p++) { 150 window.cart.initializeAddToCartButtons(mountedPanels[p]); 151 } 152 } 153 refreshMountedPrices(); 154 } 155
156 // Los .price[data-sku] de las cards reciben promos/tachado de cart.js 157 // (applyToPricesUI), que corre en DOMContentLoaded â antes de que exista 158 // ningún panel montado en diferido. Tras montar, re-lanzamos la pasada 159 // (re-ejecutable: cart.js la guarda con un contador de generación) con 160 // debounce para coalescer las tandas del pre-montaje idle. 161 var priceRefreshTimer = null; 162 function refreshMountedPrices() { 163 if (priceRefreshTimer) clearTimeout(priceRefreshTimer); 164 priceRefreshTimer = setTimeout(function () { 165 priceRefreshTimer = null; 166 if (window.cart && typeof window.cart.applyToPricesUI === 'function') { 167 window.cart.applyToPricesUI(); 168 } 169 }, 150); 170 } 171 172 // Safari no soporta requestIdleCallback: fallback a setTimeout. 173 var idle = 174 window.requestIdleCallback || 175 function (fn) { 176 return setTimeout(fn, 200); 177 }; 178 var cancelIdle = window.cancelIdleCallback || clearTimeout; 179 180 // Pre-monta el resto de L2 en tandas (un L1 por slice, sin long tasks) para 181 // que cualquier push L1âL2 posterior sea pura animación. 182 function premountNext() { 183 idleHandle = null; 184 var tpl = drawer.querySelector('template[data-l2-template]'); 185 if (!tpl) return; 186 mountL2Panels(tpl.getAttribute('data-l1-parent')); 187 syncPanels(); 188 idleHandle = idle(premountNext); 189 } 190 191 function pushL1(id) { 192 mountL2Panels(id); // los destinos L2 existen ANTES del push (ver arriba) 193 stack.push({ type: 'l1', id: id }); 194 syncPanels(); 195 focusAfterTransition(); 196 } 197 function pushL2(id) { 198 // Solo permitimos un L2 después de un L1. Guardamos el id del L1 actual 199 // como `parent` del L2. 200 if (stack.length === 0 || stack[stack.length - 1].type !== 'l1') return; 201 var parent = stack[stack.length - 1].id; 202 mountL2Panels(parent); // red de seguridad (idempotente; normalmente no-op) 203 stack.push({ type: 'l2', id: id, parent: parent }); 204 syncPanels(); 205 focusAfterTransition(); 206 } 207 function popLevel() { 208 if (stack.length === 0) return; 209 stack.pop(); 210 syncPanels(); 211 focusAfterTransition(); 212 } 213 function resetStack() { 214 stack.length = 0; 215 syncPanels(); 216 } 217 function focusAfterTransition() { 218 setTimeout(function () { 219 var focusables = getFocusable(); 220 if (focusables.length) focusables[0].focus(); 221 }, ANIM_MS); 222 } 223 224 function open() { 225 // Cancela el cierre pendiente (reapertura durante is-closing): sin esto el 226 // timer de close() remataba después con hidden=true â menú fantasma. 227 if (closeTimer) { 228 clearTimeout(closeTimer); 229 closeTimer = null; 230 } 231 drawer.classList.remove('is-closing'); 232 if (drawer.classList.contains('is-open') || drawer.classList.contains('is-opening')) return; 233 lastFocused = document.activeElement; 234 drawer.hidden = false; 235 drawer.setAttribute('aria-hidden', 'false'); 236 if (openBtn) openBtn.setAttribute('aria-expanded', 'true'); 237 resetStack(); 238 // Doble rAF en lugar del reflow forzado (void offsetWidth): el primer 239 // frame pinta el estado inicial (paneles fuera de pantalla) y el segundo 240 // dispara la transición. Regla: en el handler del tap no se lee ninguna 241 // propiedad geométrica del drawer después de mutarlo â eso obligaba a 242 // resolver style+layout de todo el drawer con el dedo esperando. 243 drawer.classList.add('is-opening'); 244 requestAnimationFrame(function () { 245 requestAnimationFrame(function () { 246 // Si close() abortó la apertura en esta ventana (megamenu:expand, 247 // media query change), no la resucitamos. 248 if (!drawer.classList.contains('is-opening')) return; 249 drawer.classList.add('is-open'); 250 drawer.classList.remove('is-opening'); 251 }); 252 }); 253 savedScrollY = window.scrollY || window.pageYOffset || 0; 254 lockBodyScroll(savedScrollY); 255 document.body.classList.add('body--drawer-open'); 256 // body--lock-stuck solo cuando hay scroll real â evita "salto" del 257 // header cuando estamos arriba del todo y el preheader es visible. 258 if (savedScrollY > 0) { 259 document.body.classList.add('body--lock-stuck'); 260 } 261 document.addEventListener('keydown', onKey); 262 setTimeout(function () { 263 var focusables = getFocusable(); 264 if (focusables.length) focusables[0].focus(); 265 }, ANIM_MS); 266 if (!idleHandle) idleHandle = idle(premountNext); 267 } 268 269 function close() { 270 // Cierre durante la ventana de apertura (los rAF de open() aún no han 271 // puesto is-open): aborta la apertura EN SECO â sin is-closing ni timer, 272 // porque no hay transición pintada que dejar acabar. Sin esto, el close() 273 // de megamenu:expand / media query hacÃa no-op y los rAF abrÃan el drawer 274 // después. 275 var aborting = drawer.classList.contains('is-opening'); 276 if (!aborting && !drawer.classList.contains('is-open')) return; 277 drawer.classList.remove('is-open'); 278 drawer.classList.remove('is-opening'); 279 if (!aborting) drawer.classList.add('is-closing'); 280 if (openBtn) openBtn.setAttribute('aria-expanded', 'false'); 281 document.removeEventListener('keydown', onKey); 282 document.body.classList.remove('body--drawer-open'); 283 document.body.classList.remove('body--lock-stuck'); 284 unlockBodyScroll(); 285 window.scrollTo(0, savedScrollY); 286 // Pausa el pre-montaje idle: con el drawer cerrado la página conserva el 287 // beneficio de los nodos fuera del DOM (se retoma en la próxima apertura). 288 if (idleHandle) { 289 cancelIdle(idleHandle); 290 idleHandle = null; 291 } 292 var finish = function () { 293 closeTimer = null; 294 drawer.classList.remove('is-closing'); 295 drawer.hidden = true; 296 drawer.setAttribute('aria-hidden', 'true'); 297 resetStack(); 298 var target = openBtn || lastFocused;
299 if (target && typeof target.focus === 'function') target.focus(); 300 }; 301 if (aborting) finish(); 302 else closeTimer = setTimeout(finish, ANIM_MS); 303 } 304 305 function onKey(e) { 306 if (e.key === 'Escape' || e.keyCode === 27) { 307 e.stopPropagation(); 308 // En depth > 0 escape vuelve al nivel anterior, no cierra el drawer. 309 if (stack.length > 0) popLevel(); 310 else close(); 311 return; 312 } 313 if (e.key === 'Tab' || e.keyCode === 9) { 314 var focusables = getFocusable(); 315 if (focusables.length === 0) return; 316 var first = focusables[0]; 317 var last = focusables[focusables.length - 1]; 318 if (e.shiftKey && document.activeElement === first) { 319 e.preventDefault(); 320 last.focus(); 321 } else if (!e.shiftKey && document.activeElement === last) { 322 e.preventDefault(); 323 first.focus(); 324 } 325 } 326 } 327 328 // Hamburger toggle (el icono interior anima a X via Tailwind según 329 // aria-expanded del botón). 330 if (openBtn) { 331 openBtn.addEventListener('click', function () { 332 if (drawer.classList.contains('is-open')) close(); 333 else open(); 334 }); 335 } 336 337 // Clicks del drawer DELEGADOS (un solo listener): los paneles L2 se montan 338 // en diferido, asà que un binding por elemento en el init dejarÃa sin 339 // handler sus botones "volver" y sus links. Orden por especificidad; cada 340 // rama replica el comportamiento del binding original. 341 drawer.addEventListener('click', function (e) { 342 var t = e.target; 343 if (!t || !t.closest) return; 344 345 // Item del L0 con data-drawer-l1: empuja L1 (sin navegar). 346 var l1Trigger = t.closest('[data-drawer-l1]'); 347 if (l1Trigger && drawer.contains(l1Trigger)) { 348 var l1Id = l1Trigger.getAttribute('data-drawer-l1'); 349 if (l1Id) { 350 e.preventDefault(); 351 pushL1(l1Id); 352 return; 353 } 354 // data-drawer-l1 vacÃo: sin subnivel y excluido del cierre (como antes). 355 return; 356 } 357 358 // Item del L1 con data-drawer-l2: empuja L2. 359 var l2Trigger = t.closest('[data-drawer-l2]'); 360 if (l2Trigger && drawer.contains(l2Trigger)) { 361 var l2Id = l2Trigger.getAttribute('data-drawer-l2'); 362 if (l2Id) { 363 e.preventDefault(); 364 pushL2(l2Id); 365 } 366 return; 367 } 368 369 // Back: vuelve un nivel. 370 var back = t.closest('[data-drawer-back], [data-drawer-back-l2]'); 371 if (back && drawer.contains(back)) { 372 popLevel(); 373 return; 374 } 375 376 var closeEl = t.closest('[data-drawer-close]'); 377 if (closeEl && drawer.contains(closeEl)) { 378 close(); 379 return; 380 } 381 382 // Link sin trigger de subnivel: cerramos el drawer (navegación real). 383 var link = t.closest('a[href]'); 384 if (link && drawer.contains(link)) close(); 385 }); 386 387 // Si el viewport se hace >=1024 con el drawer abierto, lo cerramos â salvo 388 // que el nav no quepa (body--nav-collapsed, ver megamenu-collapse.js): en 389 // ese modo el drawer ES el menú también en desktop. 390 var mqDesktop = window.matchMedia('(min-width: 1024px)'); 391 var onMqChange = function (e) { 392 if (e.matches && !document.body.classList.contains('body--nav-collapsed')) close(); 393 }; 394 if (mqDesktop.addEventListener) mqDesktop.addEventListener('change', onMqChange); 395 else mqDesktop.addListener(onMqChange); 396 397 // Cuando el nav vuelve a caber (modo expandido), el drawer desaparece del 398 // header: si estaba abierto, ciérralo para no dejar el body lockeado. 399 window.addEventListener('megamenu:expand', close); 400 401 // Estado inicial de paneles (incluso con drawer cerrado, paneles fuera). 402 syncPanels(); 403})();
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.