PageSourceSearch

https://cocunat.com/js/megamenu/megamenu-drawer.js

js cocunat.com collected 2026-09-24 17:34:43 UTC 16,239 bytes, 403 lines download raw bytes

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.