PageSourceSearch

https://www.elle.cz/js/modules/desktopNavigation.js

js elle.cz collected 2026-10-02 07:55:37 UTC 14,178 bytes, 487 lines download raw bytes

1import {setAriaExpanded, setElementHidden} from '../core/aria.js';
2import {getElementOuterWidth, parsePixelValue, parsePositiveInteger} from '../core/measure.js';
3import {createRafScheduler} from '../core/rafScheduler.js';
4
5const DESKTOP_NAV_CONFIG = {
6  triggerMode: 'hover',
7  openDelayMs: 40,
8  closeDelayMs: 140
9};
10
11const getDropdownOpenState = (dropdown) => dropdown.classList.contains('is-open');
12
13const setDropdownOpenState = (dropdown, isOpen) => {
14  dropdown.classList.toggle('is-open', isOpen);
15
16  const trigger = dropdown.querySelector('.mega-menu-dropdown__trigger');
17  const panel = dropdown.querySelector('.mega-menu-dropdown__panel');
18  setAriaExpanded(trigger, isOpen);
19
20  if (panel) {
21    setElementHidden(panel, !isOpen);
22  }
23};
24
25const findOpenDropdown = (dropdowns) => dropdowns.find((dropdown) => getDropdownOpenState(dropdown)) || null;
26
27const closeDropdowns = (dropdowns, exceptDropdown = null) => {
28  dropdowns.forEach((dropdown) => {
29    if (dropdown !== exceptDropdown) {
30      setDropdownOpenState(dropdown, false);
31    }
32  });
33};
34
35const closeNavigationItem = (element) => {
36  if (element.matches('[data-mega-menu-dropdown]')) {
37    setDropdownOpenState(element, false);
38  }
39
40  if (element.matches('[data-header-submenu]')) {
41    element.classList.remove('is-open');
42    setAriaExpanded(element.querySelector('.header-submenu__trigger'), false);
43    setElementHidden(element.querySelector('.header-submenu__panel'), true);
44  }
45};
46
47const openDropdown = (state, dropdown, suppressPanelAnimation = false) => {
48  const currentOpenDropdown = findOpenDropdown(state.dropdowns);
49  if (currentOpenDropdown === dropdown) {
50    return;
51  }
52
53  if (suppressPanelAnimation && state.navRoot instanceof HTMLElement) {
54    state.navRoot.classList.add('desktop-nav--switching');
55  }
56
57  document.querySelectorAll('[data-header-submenu].is-open').forEach((submenu) => {
58    if (submenu instanceof HTMLElement) {
59      closeNavigationItem(submenu);
60    }
61  });
62  closeDropdowns(state.dropdowns, dropdown);
63  setDropdownOpenState(dropdown, true);
64
65  if (suppressPanelAnimation && state.navRoot instanceof HTMLElement) {
66    requestAnimationFrame(() => {
67      state.navRoot.classList.remove('desktop-nav--switching');
68    });
69  }
70};
71
72const createDesktopNavState = () => {
73  const dropdowns = Array.from(document.querySelectorAll('[data-mega-menu-dropdown]'));
74  const secondaryNav = document.querySelector('.desktop-nav__categories');
75  if (!(secondaryNav instanceof HTMLElement)) {
76    return null;
77  }
78
79  const overflowMenu = secondaryNav.querySelector('[data-desktop-nav-overflow]');
80  const overflowItems = overflowMenu instanceof HTMLElement
81    ? Array.from(overflowMenu.querySelectorAll('[data-desktop-nav-overflow-item]'))
82    : [];
83
84  if (overflowMenu instanceof HTMLElement) {
85    const pinnedImageItem = Array.from(secondaryNav.children).find((item) => (
86      item instanceof HTMLElement && item.classList.contains('desktop-category-image-link')
87    ));
88    secondaryNav.insertBefore(overflowMenu, pinnedImageItem || null);
89  }
90
91  return {
92    dropdowns,
93    navRoot: document.querySelector('.desktop-nav'),
94    secondaryNav,
95    overflowMenu,
96    overflowItems,
97    visibleItemLimit: parsePositiveInteger(secondaryNav.dataset.desktopNavVisibleCount),
98    config: DESKTOP_NAV_CONFIG,
99    openTimer: null,
100    closeTimer: null,
101    fitScheduler: null,
102    resizeObserver: null
103  };
104};
105
106const clearDesktopNavTimers = (state) => {
107  window.clearTimeout(state.openTimer);
108  window.clearTimeout(state.closeTimer);
109};
110
111const bindDesktopDropdownHoverEvents = (state, dropdown) => {
112  dropdown.addEventListener('mouseenter', () => {
113    clearDesktopNavTimers(state);
114
115    const currentOpenDropdown = findOpenDropdown(state.dropdowns);
116    if (currentOpenDropdown && currentOpenDropdown !== dropdown) {
117      openDropdown(state, dropdown, true);
118      return;
119    }
120
121    state.openTimer = window.setTimeout(() => {
122      openDropdown(state, dropdown);
123    }, state.config.openDelayMs);
124  });
125
126  dropdown.addEventListener('mouseleave', (event) => {
127    const nextTarget = event.relatedTarget;
128    if (
129      nextTarget instanceof Node &&
130      state.dropdowns.some((candidateDropdown) => candidateDropdown.contains(nextTarget))
131    ) {
132      return;
133    }
134
135    clearDesktopNavTimers(state);
136    state.closeTimer = window.setTimeout(() => {
137      closeDropdowns(state.dropdowns);
138    }, state.config.closeDelayMs);
139  });
140};
141
142const bindDesktopDropdownClickEvents = (state, dropdown, trigger) => {
143  trigger.addEventListener('click', () => {
144    const shouldOpen = !getDropdownOpenState(dropdown);
145    closeDropdowns(state.dropdowns);
146    if (shouldOpen) {
147      openDropdown(state, dropdown);
148    }
149  });
150};
151
152const bindDesktopDropdownEvents = (state) => {
153  const isClickMode = state.config.triggerMode === 'click';
154
155  state.dropdowns.forEach((dropdown) => {
156    const trigger = dropdown.querySelector('.mega-menu-dropdown__trigger');
157    const closeButton = dropdown.querySelector('[data-dropdown-close]');
158    if (!(trigger instanceof HTMLElement)) {
159      return;
160    }
161
162    if (isClickMode) {
163      bindDesktopDropdownClickEvents(state, dropdown, trigger);
164    } else {
165      bindDesktopDropdownHoverEvents(state, dropdown);
166    }
167
168    dropdown.addEventListener('focusin', () => {
169      openDropdown(state, dropdown);
170    });
171
172    if (closeButton instanceof HTMLElement) {
173      closeButton.addEventListener('click', () => {
174        closeDropdowns(state.dropdowns);
175      });
176    }
177  });
178};
179
180const bindDesktopGlobalEvents = (state) => {
181  const isClickMode = state.config.triggerMode === 'click';
182
183  document.addEventListener('click', (event) => {
184    if (!isClickMode) {
185      return;
186    }
187
188    const target = event.target;
189    if (!(target instanceof Node)) {
190      return;
191    }
192
193    const clickedInsideDropdown = state.dropdowns.some((dropdown) => dropdown.contains(target));
194    if (!clickedInsideDropdown) {
195      closeDropdowns(state.dropdowns);
196    }
197  });
198
199  document.addEventListener('keydown', (event) => {
200    if (event.key === 'Escape') {
201      closeDropdowns(state.dropdowns);
202    }
203  });
204};
205
206const getSecondaryNavItems = (state) => {
207  if (!(state.secondaryNav instanceof HTMLElement)) {
208    return [];
209  }
210
211  return Array.from(state.secondaryNav.children).filter((item) => (
212    item instanceof HTMLElement && item !== state.overflowMenu
213  ));
214};
215
216const getSecondaryNavGap = (secondaryNav) => {
217  const styles = window.getComputedStyle(secondaryNav);
218  return parsePixelValue(styles.columnGap || styles.gap);
219};
220
221const getOverflowCandidateIndexes = (items) => {
222  const logoIndexes = [];
223  const menuIndexes = [];
224
225  items.forEach((item, index) => {
226    if (item.classList.contains('desktop-category-image-link')) {
227      logoIndexes.push(index);
228      return;
229    }
230
231    menuIndexes.push(index);
232  });
233
234  return [...menuIndexes.reverse(), ...logoIndexes.reverse()];
235};
236
237const getConfiguredHiddenIndexes = (items, visibleItemLimit) => {
238  const hiddenIndexes = new Set();
239  if (visibleItemLimit <= 0) {
240    return hiddenIndexes;
241  }
242
243  let textItemIndex = 0;
244  items.forEach((item, index) => {
245    if (item.classList.contains('desktop-category-image-link')) {
246      return;
247    }
248
249    if (textItemIndex >= visibleItemLimit) {
250      hiddenIndexes.add(index);
251    }
252    textItemIndex++;
253  });
254
255  return hiddenIndexes;
256};
257
258const getItemsWidth = (itemWidths, gapWidth) => (
259  itemWidths.reduce((total, itemWidth) => total + itemWidth, 0)
260  + gapWidth * Math.max(itemWidths.length - 1, 0)
261);
262
263const calculateOverflowItemIndexes = (
264  items,
265  itemWidths,
266  overflowWidth,
267  gapWidth,
268  availableWidth,
269  initiallyHiddenIndexes = new Set()
270) => {
271  const hiddenIndexes = new Set(initiallyHiddenIndexes);
272  const widthWithoutOverflow = getItemsWidth(itemWidths, gapWidth);
273  if (hiddenIndexes.size === 0 && widthWithoutOverflow <= availableWidth) {
274    return hiddenIndexes;
275  }
276
277  const visibleItemWidths = itemWidths.filter((_, index) => !hiddenIndexes.has(index));
278  let visibleWidth = getItemsWidth([...visibleItemWidths, overflowWidth], gapWidth);
279  for (const itemIndex of getOverflowCandidateIndexes(items)) {
280    if (visibleWidth <= availableWidth) {
281      break;
282    }
283
284    if (hiddenIndexes.has(itemIndex)) {
285      continue;
286    }
287
288    hiddenIndexes.add(itemIndex);
289    visibleWidth -= itemWidths[itemIndex] + gapWidth;
290  }
291
292  return hiddenIndexes;
293};
294
295const findFocusableElement = (element) => {
296  if (element.matches('a[href], button:not([disabled])')) {
297    return element;
298  }
299
300  return element.querySelector('a[href], button:not([disabled])');
301};
302
303const restoreFocusFromOverflow = (state, items, hiddenIndexes) => {
304  const activeElement = document.activeElement;
305  if (!(activeElement instanceof HTMLElement) || !(state.overflowMenu instanceof HTMLElement)) {
306    return;
307  }
308
309  if (!state.overflowMenu.contains(activeElement)) {
310    return;
311  }
312
313  const activeOverflowItem = activeElement.closest('[data-desktop-nav-overflow-item]');
314  const activeItemIndex = Number.parseInt(activeOverflowItem?.dataset.desktopNavOverflowItem || '', 10);
315  if (Number.isFinite(activeItemIndex) && hiddenIndexes.has(activeItemIndex)) {
316    return;
317  }
318
319  if (!Number.isFinite(activeItemIndex) && hiddenIndexes.size > 0) {
320    return;
321  }
322
323  const originalItem = Number.isFinite(activeItemIndex)
324    ? items[activeItemIndex]
325    : items[items.length - 1];
326  const focusTarget = originalItem instanceof HTMLElement
327    ? findFocusableElement(originalItem)
328    : null;
329  if (focusTarget instanceof HTMLElement) {
330    focusTarget.focus();
331  }
332};
333
334const moveFocusFromHiddenOriginal = (state, items, hiddenIndexes) => {
335  const activeElement = document.activeElement;
336  if (!(activeElement instanceof HTMLElement) || !(state.overflowMenu instanceof HTMLElement)) {
337    return;
338  }
339
340  const focusedItemIndex = items.findIndex((item) => item.contains(activeElement));
341  if (focusedItemIndex < 0 || !hiddenIndexes.has(focusedItemIndex)) {
342    return;
343  }
344
345  const overflowTrigger = state.overflowMenu.querySelector('.desktop-nav-more__trigger');
346  if (overflowTrigger instanceof HTMLElement) {
347    overflowTrigger.focus();
348  }
349};
350
351const updateLastVisibleItem = (state) => {
352  Array.from(state.secondaryNav.children).forEach((item) => {
353    if (item instanceof HTMLElement) {
354      item.classList.remove('desktop-nav__item--last-visible');
355    }
356  });
357
358  const visibleItems = Array.from(state.secondaryNav.children).filter((item) => (
359    item instanceof HTMLElement && !item.hidden
360  ));
361  const lastVisibleItem = visibleItems[visibleItems.length - 1];
362  if (lastVisibleItem instanceof HTMLElement) {
363    lastVisibleItem.classList.add('desktop-nav__item--last-visible');
364  }
365};
366
367const applySecondaryNavVisibility = (state, items, hiddenIndexes) => {
368  restoreFocusFromOverflow(state, items, hiddenIndexes);
369  moveFocusFromHiddenOriginal(state, items, hiddenIndexes);
370
371  items.forEach((item, index) => {
372    const isHidden = hiddenIndexes.has(index);
373    item.hidden = isHidden;
374    if (isHidden) {
375      closeNavigationItem(item);
376    }
377  });
378
379  state.overflowItems.forEach((item, index) => {
380    item.hidden = !hiddenIndexes.has(index);
381  });
382
383  if (state.overflowMenu instanceof HTMLElement) {
384    const hasOverflow = hiddenIndexes.size > 0;
385    state.overflowMenu.hidden = !hasOverflow;
386    if (!hasOverflow) {
387      closeNavigationItem(state.overflowMenu);
388    }
389  }
390
391  updateLastVisibleItem(state);
392};
393
394const fitSecondaryNavItems = (state) => {
395  if (!(state.secondaryNav instanceof HTMLElement)) {
396    return;
397  }
398
399  const items = getSecondaryNavItems(state);
400  if (!items.length) {
401    return;
402  }
403
404  const availableWidth = state.secondaryNav.getBoundingClientRect().width;
405  if (availableWidth <= 0 || !(state.overflowMenu instanceof HTMLElement)) {
406    return;
407  }
408
409  items.forEach((item) => {
410    item.hidden = false;
411    item.classList.remove('desktop-nav__item--last-visible');
412  });
413  state.overflowMenu.classList.remove('desktop-nav__item--last-visible');
414  state.overflowMenu.hidden = false;
415  const gapWidth = getSecondaryNavGap(state.secondaryNav);
416  const configuredHiddenIndexes = getConfiguredHiddenIndexes(items, state.visibleItemLimit);
417  const lastItemWithoutOverflow = items[items.length - 1];
418  lastItemWithoutOverflow.classList.add('desktop-nav__item--last-visible');
419  const itemWidthsWithoutOverflow = items.map((item) => getElementOuterWidth(item));
420  if (
421    configuredHiddenIndexes.size === 0
422    && getItemsWidth(itemWidthsWithoutOverflow, gapWidth) <= availableWidth
423  ) {
424    applySecondaryNavVisibility(state, items, configuredHiddenIndexes);
425    return;
426  }
427
428  lastItemWithoutOverflow.classList.remove('desktop-nav__item--last-visible');
429  const itemWidths = items.map((item) => getElementOuterWidth(item));
430  const overflowWidth = getElementOuterWidth(state.overflowMenu);
431  const hiddenIndexes = calculateOverflowItemIndexes(
432    items,
433    itemWidths,
434    overflowWidth,
435    gapWidth,
436    availableWidth,
437    configuredHiddenIndexes
438  );
439
440  applySecondaryNavVisibility(state, items, hiddenIndexes);
441};
442
443const scheduleSecondaryNavFit = (state) => {
444  state.fitScheduler.schedule();
445};
446
447const bindDesktopResponsiveEvents = (state) => {
448  window.addEventListener('resize', () => {
449    scheduleSecondaryNavFit(state);
450  });
451
452  window.addEventListener('load', () => {
453    scheduleSecondaryNavFit(state);
454  });
455
456  if (typeof ResizeObserver === 'function') {
457    state.resizeObserver = new ResizeObserver(() => {
458      scheduleSecondaryNavFit(state);
459    });
460    state.resizeObserver.observe(state.secondaryNav);
461  }
462
463  if (document.fonts?.ready) {
464    document.fonts.ready.then(() => {
465      scheduleSecondaryNavFit(state);
466    });
467  }
468};
469
470export const initDesktopDropdownNavigation = () => {
471  const state = createDesktopNavState();
472  if (!state) {
473    return;
474  }
475
476  state.fitScheduler = createRafScheduler(() => fitSecondaryNavItems(state));
477  closeDropdowns(state.dropdowns);
478
479  bindDesktopDropdownEvents(state);
480  bindDesktopGlobalEvents(state);
481  bindDesktopResponsiveEvents(state);
482  state.fitScheduler.flush();
483
484  if (state.navRoot instanceof HTMLElement) {
485    state.navRoot.classList.add('desktop-nav--initialized');
486  }
487};

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.