mega-menu.js

  1. /* eslint-disable class-methods-use-this */
  2. import { queryOne, queryAll } from '@ecl/dom-utils';
  3. import EventManager from '@ecl/event-manager';
  4. import isMobile from 'mobile-device-detect';
  5. import { createFocusTrap } from 'focus-trap';
  6. /**
  7. * @param {HTMLElement} element DOM element for component instantiation and scope
  8. * @param {Object} options
  9. * @param {String} options.openSelector Selector for the hamburger button
  10. * @param {String} options.backSelector Selector for the back button
  11. * @param {String} options.innerSelector Selector for the menu inner
  12. * @param {String} options.itemSelector Selector for the menu item
  13. * @param {String} options.linkSelector Selector for the menu link
  14. * @param {String} options.subLinkSelector Selector for the menu sub link
  15. * @param {String} options.megaSelector Selector for the mega menu
  16. * @param {String} options.subItemSelector Selector for the menu sub items
  17. * @param {String} options.labelOpenAttribute The data attribute for open label
  18. * @param {String} options.labelCloseAttribute The data attribute for close label
  19. * @param {Boolean} options.attachClickListener Whether or not to bind click events
  20. * @param {Boolean} options.attachHoverListener Whether or not to bind hover events
  21. * @param {Boolean} options.attachFocusListener Whether or not to bind focus events
  22. * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
  23. * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
  24. */
  25. export class MegaMenu {
  26. /**
  27. * @static
  28. * Shorthand for instance creation and initialisation.
  29. *
  30. * @param {HTMLElement} root DOM element for component instantiation and scope
  31. *
  32. * @return {Menu} An instance of Menu.
  33. */
  34. static autoInit(root, { MEGA_MENU: defaultOptions = {} } = {}) {
  35. const megaMenu = new MegaMenu(root, defaultOptions);
  36. megaMenu.init();
  37. root.ECLMegaMenu = megaMenu;
  38. return megaMenu;
  39. }
  40. /**
  41. * @event MegaMenu#onOpen
  42. */
  43. /**
  44. * @event MegaMenu#onClose
  45. */
  46. /**
  47. * @event MegaMenu#onOpenPanel
  48. */
  49. /**
  50. * @event MegaMenu#onBack
  51. */
  52. /**
  53. * @event MegaMenu#onItemClick
  54. */
  55. /**
  56. * @event MegaMenu#onFocusTrapToggle
  57. */
  58. /**
  59. * An array of supported events for this component.
  60. *
  61. * @type {Array<string>}
  62. * @memberof MegaMenu
  63. */
  64. supportedEvents = ['onOpen', 'onClose'];
  65. constructor(
  66. element,
  67. {
  68. openSelector = '[data-ecl-mega-menu-open]',
  69. backSelector = '[data-ecl-mega-menu-back]',
  70. innerSelector = '[data-ecl-mega-menu-inner]',
  71. itemSelector = '[data-ecl-mega-menu-item]',
  72. linkSelector = '[data-ecl-mega-menu-link]',
  73. subLinkSelector = '[data-ecl-mega-menu-sublink]',
  74. megaSelector = '[data-ecl-mega-menu-mega]',
  75. containerSelector = '[data-ecl-has-container]',
  76. subItemSelector = '[data-ecl-mega-menu-subitem]',
  77. featuredAttribute = '[data-ecl-mega-menu-featured]',
  78. featuredLinkAttribute = '[data-ecl-mega-menu-featured-link]',
  79. labelOpenAttribute = 'data-ecl-mega-menu-label-open',
  80. labelCloseAttribute = 'data-ecl-mega-menu-label-close',
  81. attachClickListener = true,
  82. attachFocusListener = true,
  83. attachKeyListener = true,
  84. attachResizeListener = true,
  85. } = {},
  86. ) {
  87. // Check element
  88. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  89. throw new TypeError(
  90. 'DOM element should be given to initialize this widget.',
  91. );
  92. }
  93. this.element = element;
  94. this.eventManager = new EventManager();
  95. // Options
  96. this.openSelector = openSelector;
  97. this.backSelector = backSelector;
  98. this.innerSelector = innerSelector;
  99. this.itemSelector = itemSelector;
  100. this.linkSelector = linkSelector;
  101. this.subLinkSelector = subLinkSelector;
  102. this.megaSelector = megaSelector;
  103. this.subItemSelector = subItemSelector;
  104. this.containerSelector = containerSelector;
  105. this.labelOpenAttribute = labelOpenAttribute;
  106. this.labelCloseAttribute = labelCloseAttribute;
  107. this.attachClickListener = attachClickListener;
  108. this.attachFocusListener = attachFocusListener;
  109. this.attachKeyListener = attachKeyListener;
  110. this.attachResizeListener = attachResizeListener;
  111. this.featuredAttribute = featuredAttribute;
  112. this.featuredLinkAttribute = featuredLinkAttribute;
  113. // Private variables
  114. this.direction = 'ltr';
  115. this.open = null;
  116. this.toggleLabel = null;
  117. this.back = null;
  118. this.backItemLevel1 = null;
  119. this.backItemLevel2 = null;
  120. this.inner = null;
  121. this.items = null;
  122. this.links = null;
  123. this.isOpen = false;
  124. this.resizeTimer = null;
  125. this.wrappers = null;
  126. this.isKeyEvent = false;
  127. this.isDesktop = false;
  128. this.isLarge = false;
  129. this.lastVisibleItem = null;
  130. this.menuOverlay = null;
  131. this.currentItem = null;
  132. this.totalItemsWidth = 0;
  133. this.breakpointL = 996;
  134. this.openPanel = { num: 0, item: {} };
  135. this.infoLinks = null;
  136. this.seeAllLinks = null;
  137. this.featuredLinks = null;
  138. // Bind `this` for use in callbacks
  139. this.handleClickOnOpen = this.handleClickOnOpen.bind(this);
  140. this.handleClickOnClose = this.handleClickOnClose.bind(this);
  141. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  142. this.handleClickOnBack = this.handleClickOnBack.bind(this);
  143. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  144. this.handleClickOnItem = this.handleClickOnItem.bind(this);
  145. this.handleClickOnSubitem = this.handleClickOnSubitem.bind(this);
  146. this.handleFocusOut = this.handleFocusOut.bind(this);
  147. this.handleKeyboard = this.handleKeyboard.bind(this);
  148. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  149. this.handleResize = this.handleResize.bind(this);
  150. this.useDesktopDisplay = this.useDesktopDisplay.bind(this);
  151. this.closeOpenDropdown = this.closeOpenDropdown.bind(this);
  152. this.checkDropdownHeight = this.checkDropdownHeight.bind(this);
  153. this.positionMenuOverlay = this.positionMenuOverlay.bind(this);
  154. this.resetStyles = this.resetStyles.bind(this);
  155. this.handleFirstPanel = this.handleFirstPanel.bind(this);
  156. this.handleSecondPanel = this.handleSecondPanel.bind(this);
  157. this.disableScroll = this.disableScroll.bind(this);
  158. this.enableScroll = this.enableScroll.bind(this);
  159. }
  160. /**
  161. * Initialise component.
  162. */
  163. init() {
  164. if (!ECL) {
  165. throw new TypeError('Called init but ECL is not present');
  166. }
  167. ECL.components = ECL.components || new Map();
  168. // Query elements
  169. this.open = queryOne(this.openSelector, this.element);
  170. this.back = queryOne(this.backSelector, this.element);
  171. this.inner = queryOne(this.innerSelector, this.element);
  172. this.btnPrevious = queryOne(this.buttonPreviousSelector, this.element);
  173. this.btnNext = queryOne(this.buttonNextSelector, this.element);
  174. this.items = queryAll(this.itemSelector, this.element);
  175. this.subItems = queryAll(this.subItemSelector, this.element);
  176. this.links = queryAll(this.linkSelector, this.element);
  177. this.header = queryOne('.ecl-site-header', document);
  178. this.headerBanner = queryOne('.ecl-site-header__banner', document);
  179. this.wrappers = queryAll('.ecl-mega-menu__wrapper', this.element);
  180. this.headerNotification = queryOne(
  181. '.ecl-site-header__notification',
  182. document,
  183. );
  184. this.toggleLabel = queryOne('.ecl-button__label', this.open);
  185. this.menuOverlay = queryOne('.ecl-mega-menu__overlay', this.element);
  186. // Check if we should use desktop display (it does not rely only on breakpoints)
  187. this.isDesktop = this.useDesktopDisplay();
  188. // Bind click events on buttons
  189. if (this.attachClickListener) {
  190. // Open
  191. if (this.open) {
  192. this.open.addEventListener('click', this.handleClickOnToggle);
  193. }
  194. // Back
  195. if (this.back) {
  196. this.back.addEventListener('click', this.handleClickOnBack);
  197. this.back.addEventListener('keyup', this.handleKeyboard);
  198. }
  199. // Global click
  200. if (this.attachClickListener) {
  201. document.addEventListener('click', this.handleClickGlobal);
  202. }
  203. }
  204. // Bind event on menu links
  205. if (this.links) {
  206. this.links.forEach((link) => {
  207. if (this.attachFocusListener) {
  208. link.addEventListener('focusout', this.handleFocusOut);
  209. }
  210. if (this.attachKeyListener) {
  211. link.addEventListener('keyup', this.handleKeyboard);
  212. }
  213. });
  214. }
  215. // Bind event on sub menu links
  216. if (this.subItems) {
  217. this.subItems.forEach((subItem) => {
  218. const subLink = queryOne('.ecl-mega-menu__sublink', subItem);
  219. if (this.attachKeyListener && subLink) {
  220. subLink.addEventListener('click', this.handleClickOnSubitem);
  221. subLink.addEventListener('keyup', this.handleKeyboard);
  222. }
  223. if (this.attachFocusListener && subLink) {
  224. subLink.addEventListener('focusout', this.handleFocusOut);
  225. }
  226. });
  227. }
  228. this.infoLinks = queryAll('.ecl-mega-menu__info-link a', this.element);
  229. if (this.infoLinks.length > 0) {
  230. this.infoLinks.forEach((infoLink) => {
  231. if (this.attachKeyListener) {
  232. infoLink.addEventListener('keyup', this.handleKeyboard);
  233. }
  234. if (this.attachFocusListener) {
  235. infoLink.addEventListener('blur', this.handleFocusOut);
  236. }
  237. });
  238. }
  239. this.seeAllLinks = queryAll('.ecl-mega-menu__see-all a', this.element);
  240. if (this.seeAllLinks.length > 0) {
  241. this.seeAllLinks.forEach((seeAll) => {
  242. if (this.attachKeyListener) {
  243. seeAll.addEventListener('keyup', this.handleKeyboard);
  244. }
  245. if (this.attachFocusListener) {
  246. seeAll.addEventListener('blur', this.handleFocusOut);
  247. }
  248. });
  249. }
  250. this.featuredLinks = queryAll(this.featuredLinkAttribute, this.element);
  251. if (this.featuredLinks.length > 0 && this.attachFocusListener) {
  252. this.featuredLinks.forEach((featured) => {
  253. featured.addEventListener('blur', this.handleFocusOut);
  254. });
  255. }
  256. // Bind global keyboard events
  257. if (this.attachKeyListener) {
  258. document.addEventListener('keyup', this.handleKeyboardGlobal);
  259. }
  260. // Bind resize events
  261. if (this.attachResizeListener) {
  262. window.addEventListener('resize', this.handleResize);
  263. }
  264. // Browse first level items
  265. if (this.items) {
  266. this.items.forEach((item) => {
  267. // Check menu item display (right to left, full width, ...)
  268. this.totalItemsWidth += item.offsetWidth;
  269. if (
  270. item.hasAttribute('data-ecl-has-children') ||
  271. item.hasAttribute('data-ecl-has-container')
  272. ) {
  273. // Bind click event on menu links
  274. const link = queryOne(this.linkSelector, item);
  275. if (this.attachClickListener && link) {
  276. link.addEventListener('click', this.handleClickOnItem);
  277. }
  278. }
  279. });
  280. }
  281. // Create a focus trap around the menu
  282. this.focusTrap = createFocusTrap(this.element, {
  283. onActivate: () =>
  284. this.element.classList.add('ecl-mega-menu-trap-is-active'),
  285. onDeactivate: () =>
  286. this.element.classList.remove('ecl-mega-menu-trap-is-active'),
  287. });
  288. this.handleResize();
  289. // Set ecl initialized attribute
  290. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  291. ECL.components.set(this.element, this);
  292. }
  293. /**
  294. * Register a callback function for a specific event.
  295. *
  296. * @param {string} eventName - The name of the event to listen for.
  297. * @param {Function} callback - The callback function to be invoked when the event occurs.
  298. * @returns {void}
  299. * @memberof MegaMenu
  300. * @instance
  301. *
  302. * @example
  303. * // Registering a callback for the 'onOpen' event
  304. * megaMenu.on('onOpen', (event) => {
  305. * console.log('Open event occurred!', event);
  306. * });
  307. */
  308. on(eventName, callback) {
  309. this.eventManager.on(eventName, callback);
  310. }
  311. /**
  312. * Trigger a component event.
  313. *
  314. * @param {string} eventName - The name of the event to trigger.
  315. * @param {any} eventData - Data associated with the event.
  316. * @memberof MegaMenu
  317. */
  318. trigger(eventName, eventData) {
  319. this.eventManager.trigger(eventName, eventData);
  320. }
  321. /**
  322. * Destroy component.
  323. */
  324. destroy() {
  325. if (this.attachClickListener) {
  326. if (this.open) {
  327. this.open.removeEventListener('click', this.handleClickOnToggle);
  328. }
  329. if (this.back) {
  330. this.back.removeEventListener('click', this.handleClickOnBack);
  331. }
  332. if (this.attachClickListener) {
  333. document.removeEventListener('click', this.handleClickGlobal);
  334. }
  335. }
  336. if (this.links) {
  337. this.links.forEach((link) => {
  338. if (this.attachClickListener) {
  339. link.removeEventListener('click', this.handleClickOnItem);
  340. }
  341. if (this.attachFocusListener) {
  342. link.removeEventListener('focusout', this.handleFocusOut);
  343. }
  344. if (this.attachKeyListener) {
  345. link.removeEventListener('keyup', this.handleKeyboard);
  346. }
  347. });
  348. }
  349. if (this.subItems) {
  350. this.subItems.forEach((subItem) => {
  351. const subLink = queryOne('.ecl-mega-menu__sublink', subItem);
  352. if (this.attachKeyListener && subLink) {
  353. subLink.removeEventListener('keyup', this.handleKeyboard);
  354. }
  355. if (this.attachClickListener && subLink) {
  356. subLink.removeEventListener('click', this.handleClickOnSubitem);
  357. }
  358. if (this.attachFocusListener && subLink) {
  359. subLink.removeEventListener('focusout', this.handleFocusOut);
  360. }
  361. });
  362. }
  363. if (this.infoLinks) {
  364. this.infoLinks.forEach((infoLink) => {
  365. if (this.attachFocusListener) {
  366. infoLink.removeEventListener('blur', this.handleFocusOut);
  367. }
  368. if (this.attachKeyListener) {
  369. infoLink.removeEventListener('keyup', this.handleKeyboard);
  370. }
  371. });
  372. }
  373. if (this.seeAllLinks) {
  374. this.seeAllLinks.forEach((seeAll) => {
  375. if (this.attachFocusListener) {
  376. seeAll.removeEventListener('blur', this.handleFocusOut);
  377. }
  378. if (this.attachKeyListener) {
  379. seeAll.removeEventListener('keyup', this.handleKeyboard);
  380. }
  381. });
  382. }
  383. if (this.featuredLinks && this.attachFocusListener) {
  384. this.featuredLinks.forEach((featuredLink) => {
  385. featuredLink.removeEventListener('blur', this.handleFocusOut);
  386. });
  387. }
  388. if (this.attachKeyListener) {
  389. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  390. }
  391. if (this.attachResizeListener) {
  392. window.removeEventListener('resize', this.handleResize);
  393. }
  394. this.closeOpenDropdown();
  395. this.enableScroll();
  396. if (this.element) {
  397. this.element.removeAttribute('data-ecl-auto-initialized');
  398. ECL.components.delete(this.element);
  399. }
  400. }
  401. /**
  402. * Disable page scrolling
  403. */
  404. disableScroll() {
  405. document.body.classList.add('ecl-mega-menu-prevent-scroll');
  406. }
  407. /**
  408. * Enable page scrolling
  409. */
  410. enableScroll() {
  411. document.body.classList.remove('ecl-mega-menu-prevent-scroll');
  412. }
  413. /**
  414. * Check if desktop display has to be used
  415. * - not using a phone or tablet (whatever the screen size is)
  416. * - not having hamburger menu on screen
  417. */
  418. useDesktopDisplay() {
  419. // Detect mobile devices
  420. if (isMobile.isMobileOnly) {
  421. return false;
  422. }
  423. // Force mobile display on tablet
  424. if (isMobile.isTablet) {
  425. this.element.classList.add('ecl-mega-menu--forced-mobile');
  426. return false;
  427. }
  428. // After all that, check if the hamburger button is displayed
  429. if (window.innerWidth < this.breakpointL) {
  430. return false;
  431. }
  432. // Everything is fine to use desktop display
  433. this.element.classList.remove('ecl-mega-menu--forced-mobile');
  434. return true;
  435. }
  436. /**
  437. * Reset the styles set by the script
  438. *
  439. * @param {string} desktop or mobile
  440. */
  441. resetStyles(viewport, compact) {
  442. const infoPanels = queryAll('.ecl-mega-menu__info', this.element);
  443. const subLists = queryAll('.ecl-mega-menu__sublist', this.element);
  444. // Remove display:none from the sublists
  445. if (subLists && viewport === 'mobile') {
  446. const megaMenus = queryAll(
  447. '.ecl-mega-menu__item > .ecl-mega-menu__wrapper > .ecl-container > [data-ecl-mega-menu-mega]',
  448. this.element,
  449. );
  450. megaMenus.forEach((menu) => {
  451. menu.style.height = '';
  452. });
  453. // Reset top position and height of the wrappers
  454. if (this.wrappers) {
  455. this.wrappers.forEach((wrapper) => {
  456. wrapper.style.top = '';
  457. wrapper.style.height = '';
  458. });
  459. }
  460. if (this.openPanel.num > 0) {
  461. if (this.header) {
  462. if (this.headerBanner) {
  463. this.headerBanner.style.display = 'none';
  464. }
  465. if (this.headerNotification) {
  466. this.headerNotification.style.display = 'none';
  467. }
  468. }
  469. }
  470. // Two panels are opened
  471. if (this.openPanel.num === 2) {
  472. const subItemExpanded = queryOne(
  473. '.ecl-mega-menu__subitem--expanded',
  474. this.element,
  475. );
  476. if (subItemExpanded) {
  477. subItemExpanded.firstChild.classList.add(
  478. 'ecl-mega-menu__parent-link',
  479. );
  480. }
  481. const menuItem = this.openPanel.item;
  482. // Hide siblings
  483. const siblings = menuItem.parentNode.childNodes;
  484. siblings.forEach((sibling) => {
  485. if (sibling !== menuItem) {
  486. sibling.style.display = 'none';
  487. }
  488. });
  489. }
  490. } else if (subLists && viewport === 'desktop' && !compact) {
  491. // Reset styles for the sublist and subitems
  492. subLists.forEach((list) => {
  493. list.classList.remove('ecl-mega-menu__sublist--scrollable');
  494. list.childNodes.forEach((item) => {
  495. item.style.display = '';
  496. });
  497. });
  498. infoPanels.forEach((info) => {
  499. info.style.top = '';
  500. });
  501. // Check if we have an open item, if we don't hide the overlay and enable scroll
  502. const currentItems = [];
  503. const currentItem = queryOne(
  504. '.ecl-mega-menu__subitem--expanded',
  505. this.element,
  506. );
  507. if (currentItem) {
  508. currentItem.firstElementChild.classList.remove(
  509. 'ecl-mega-menu__parent-link',
  510. );
  511. currentItems.push(currentItem);
  512. }
  513. const currentSubItem = queryOne(
  514. '.ecl-mega-menu__item--expanded',
  515. this.element,
  516. );
  517. if (currentSubItem) {
  518. currentItems.push(currentSubItem);
  519. }
  520. if (currentItems.length > 0) {
  521. currentItems.forEach((current) => {
  522. this.checkDropdownHeight(current);
  523. });
  524. } else {
  525. this.element.setAttribute('aria-expanded', 'false');
  526. this.element.removeAttribute('data-expanded');
  527. this.open.setAttribute('aria-expanded', 'false');
  528. this.enableScroll();
  529. }
  530. } else if (viewport === 'desktop' && compact) {
  531. const currentSubItem = queryOne(
  532. '.ecl-mega-menu__subitem--expanded',
  533. this.element,
  534. );
  535. if (currentSubItem) {
  536. currentSubItem.firstElementChild.classList.remove(
  537. 'ecl-mega-menu__parent-link',
  538. );
  539. }
  540. infoPanels.forEach((info) => {
  541. info.style.height = '';
  542. });
  543. }
  544. if (viewport === 'desktop' && this.header) {
  545. if (this.headerBanner) {
  546. this.headerBanner.style.display = 'flex';
  547. }
  548. if (this.headerNotification) {
  549. this.headerNotification.style.display = 'flex';
  550. }
  551. }
  552. }
  553. /**
  554. * Trigger events on resize
  555. * Uses a debounce, for performance
  556. */
  557. handleResize() {
  558. clearTimeout(this.resizeTimer);
  559. this.resizeTimer = setTimeout(() => {
  560. const screenWidth = window.innerWidth;
  561. if (this.prevScreenWidth !== undefined) {
  562. // Check if the transition involves crossing the L breakpoint
  563. const isTransition =
  564. (this.prevScreenWidth <= this.breakpointL &&
  565. screenWidth > this.breakpointL) ||
  566. (this.prevScreenWidth > this.breakpointL &&
  567. screenWidth <= this.breakpointL);
  568. // If we are moving in or out the L breakpoint, reset the styles
  569. if (isTransition) {
  570. this.resetStyles(
  571. screenWidth > this.breakpointL ? 'desktop' : 'mobile',
  572. );
  573. }
  574. if (this.prevScreenWidth > 1140 && screenWidth > 996) {
  575. this.resetStyles('desktop', true);
  576. }
  577. }
  578. this.isDesktop = this.useDesktopDisplay();
  579. this.isLarge = window.innerWidth > 1140;
  580. // Update previous screen width
  581. this.prevScreenWidth = screenWidth;
  582. this.element.classList.remove('ecl-mega-menu--forced-mobile');
  583. // RTL
  584. this.direction = getComputedStyle(this.element).direction;
  585. if (this.direction === 'rtl') {
  586. this.element.classList.add('ecl-mega-menu--rtl');
  587. } else {
  588. this.element.classList.remove('ecl-mega-menu--rtl');
  589. }
  590. // Check droopdown height if needed
  591. const expanded = queryOne('.ecl-mega-menu__item--expanded', this.element);
  592. if (expanded && this.isDesktop) {
  593. this.checkDropdownHeight(expanded);
  594. }
  595. // Check the menu position
  596. this.positionMenuOverlay();
  597. }, 200);
  598. }
  599. /**
  600. * Calculate dropdown height dynamically
  601. *
  602. * @param {Node} menuItem
  603. */
  604. checkDropdownHeight(menuItem) {
  605. const infoPanel = queryOne('.ecl-mega-menu__info', menuItem);
  606. const mainPanel = queryOne('.ecl-mega-menu__mega', menuItem);
  607. // Hide the panels while calculating their heights
  608. if (mainPanel && this.isDesktop) {
  609. mainPanel.style.opacity = 0;
  610. }
  611. if (infoPanel && this.isDesktop) {
  612. infoPanel.style.opacity = 0;
  613. }
  614. setTimeout(() => {
  615. const viewportHeight = window.innerHeight;
  616. let infoPanelHeight = 0;
  617. if (this.isDesktop) {
  618. const heights = [];
  619. let height = 0;
  620. let secondPanel = null;
  621. let featuredPanel = null;
  622. let itemsHeight = 0;
  623. let subItemsHeight = 0;
  624. let featuredHeight = 0;
  625. if (infoPanel) {
  626. infoPanelHeight = infoPanel.scrollHeight + 16;
  627. }
  628. if (infoPanel && this.isLarge) {
  629. heights.push(infoPanelHeight);
  630. } else if (infoPanel && this.isDesktop) {
  631. itemsHeight = infoPanelHeight;
  632. subItemsHeight = infoPanelHeight;
  633. featuredHeight = infoPanelHeight;
  634. }
  635. if (mainPanel) {
  636. const mainTop = mainPanel.getBoundingClientRect().top;
  637. const list = queryOne('.ecl-mega-menu__sublist', mainPanel);
  638. if (!list) {
  639. const isContainer = menuItem.classList.contains(
  640. 'ecl-mega-menu__item--has-container',
  641. );
  642. if (isContainer) {
  643. const container = queryOne(
  644. '.ecl-mega-menu__mega-container',
  645. menuItem,
  646. );
  647. if (container) {
  648. container.firstElementChild.style.height = `${viewportHeight - mainTop}px`;
  649. mainPanel.style.opacity = 1;
  650. return;
  651. }
  652. }
  653. } else {
  654. const items = list.children;
  655. if (items.length > 0) {
  656. Array.from(items).forEach((item) => {
  657. itemsHeight += item.getBoundingClientRect().height;
  658. });
  659. heights.push(itemsHeight);
  660. }
  661. }
  662. }
  663. const expanded = queryOne(
  664. '.ecl-mega-menu__subitem--expanded',
  665. menuItem,
  666. );
  667. if (expanded) {
  668. secondPanel = queryOne('.ecl-mega-menu__mega--level-2', expanded);
  669. if (secondPanel) {
  670. const subItems = queryAll(`${this.subItemSelector}`, secondPanel);
  671. if (subItems.length > 0) {
  672. subItems.forEach((item) => {
  673. subItemsHeight += item.getBoundingClientRect().height;
  674. });
  675. }
  676. heights.push(subItemsHeight);
  677. // Featured panel calculations.
  678. featuredPanel = queryOne('.ecl-mega-menu__featured', expanded);
  679. if (featuredPanel) {
  680. // Get the elements inside the scrollable container and calculate their heights.
  681. Array.from(featuredPanel.firstElementChild.children).forEach(
  682. (child) => {
  683. const elStyle = window.getComputedStyle(child);
  684. const marginHeight =
  685. parseFloat(elStyle.marginTop) +
  686. parseFloat(elStyle.marginBottom);
  687. featuredHeight += child.offsetHeight + marginHeight;
  688. },
  689. );
  690. heights.push(featuredHeight);
  691. }
  692. }
  693. }
  694. const maxHeight = Math.max(...heights);
  695. const containerBounding = this.inner.getBoundingClientRect();
  696. const containerBottom = containerBounding.bottom;
  697. // By requirements, limit the height to the 70% of the available space.
  698. const availableHeight = (window.innerHeight - containerBottom) * 0.7;
  699. if (maxHeight > availableHeight) {
  700. height = availableHeight;
  701. } else {
  702. height = maxHeight;
  703. }
  704. const wrapper = queryOne('.ecl-mega-menu__wrapper', menuItem);
  705. if (wrapper) {
  706. wrapper.style.height = `${height}px`;
  707. }
  708. if (mainPanel && this.isLarge) {
  709. mainPanel.style.height = `${height}px`;
  710. } else if (mainPanel && infoPanel && this.isDesktop) {
  711. mainPanel.style.height = `${height - infoPanelHeight}px`;
  712. }
  713. if (infoPanel && this.isLarge) {
  714. infoPanel.style.height = `${height}px`;
  715. }
  716. if (secondPanel && this.isLarge) {
  717. secondPanel.style.height = `${height}px`;
  718. } else if (secondPanel && this.isDesktop) {
  719. secondPanel.style.height = `${height - infoPanelHeight}px`;
  720. }
  721. if (featuredPanel && this.isLarge) {
  722. featuredPanel.style.height = `${height}px`;
  723. } else if (featuredPanel && this.isDesktop) {
  724. featuredPanel.style.height = `${height - infoPanelHeight}px`;
  725. }
  726. }
  727. if (mainPanel && this.isDesktop) {
  728. mainPanel.style.opacity = 1;
  729. }
  730. if (infoPanel && this.isDesktop) {
  731. infoPanel.style.opacity = 1;
  732. }
  733. }, 100);
  734. }
  735. /**
  736. * Dinamically set the position of the menu overlay
  737. */
  738. positionMenuOverlay() {
  739. let availableHeight = 0;
  740. if (!this.isDesktop) {
  741. // In mobile, we get the bottom position of the site header header
  742. setTimeout(() => {
  743. if (this.header) {
  744. const position = this.header.getBoundingClientRect();
  745. const bottomPosition = Math.round(position.bottom);
  746. if (this.menuOverlay) {
  747. this.menuOverlay.style.top = `${bottomPosition}px`;
  748. }
  749. if (this.inner) {
  750. this.inner.style.top = `${bottomPosition}px`;
  751. }
  752. const item = queryOne('.ecl-mega-menu__item--expanded', this.element);
  753. if (item) {
  754. const subList = queryOne('.ecl-mega-menu__sublist', item);
  755. if (subList && this.openPanel.num === 1) {
  756. const info = queryOne('.ecl-mega-menu__info', item);
  757. if (info) {
  758. const bottomRect = info.getBoundingClientRect();
  759. const bottomInfo = bottomRect.bottom;
  760. availableHeight = window.innerHeight - bottomInfo - 16;
  761. subList.classList.add('ecl-mega-menu__sublist--scrollable');
  762. subList.style.height = `${availableHeight}px`;
  763. }
  764. } else if (subList) {
  765. subList.classList.remove('ecl-mega-menu__sublist--scrollable');
  766. subList.style.height = '';
  767. }
  768. }
  769. if (this.openPanel.num === 2) {
  770. const subItem = queryOne(
  771. '.ecl-mega-menu__subitem--expanded',
  772. this.element,
  773. );
  774. if (subItem) {
  775. const subMega = queryOne(
  776. '.ecl-mega-menu__mega--level-2',
  777. subItem,
  778. );
  779. if (subMega) {
  780. const subMegaRect = subMega.getBoundingClientRect();
  781. const subMegaTop = subMegaRect.top;
  782. availableHeight = window.innerHeight - subMegaTop;
  783. subMega.style.height = `${availableHeight}px`;
  784. }
  785. }
  786. }
  787. if (this.wrappers) {
  788. this.wrappers.forEach((wrapper) => {
  789. wrapper.style.top = '';
  790. wrapper.style.height = '';
  791. });
  792. }
  793. }
  794. }, 0);
  795. } else {
  796. setTimeout(() => {
  797. // In desktop we get the bottom position of the whole site header
  798. const siteHeader = queryOne('.ecl-site-header', document);
  799. if (siteHeader) {
  800. const headerRect = siteHeader.getBoundingClientRect();
  801. const headerBottom = headerRect.bottom;
  802. const item = queryOne(this.itemSelector, this.element);
  803. const rect = item.getBoundingClientRect();
  804. const rectHeight = rect.height;
  805. if (this.wrappers) {
  806. this.wrappers.forEach((wrapper) => {
  807. wrapper.style.top = `${rectHeight}px`;
  808. });
  809. }
  810. if (this.menuOverlay) {
  811. this.menuOverlay.style.top = `${headerBottom}px`;
  812. }
  813. } else {
  814. const bottomPosition = this.element.getBoundingClientRect().bottom;
  815. if (this.menuOverlay) {
  816. this.menuOverlay.style.top = `${bottomPosition}px`;
  817. }
  818. }
  819. }, 0);
  820. }
  821. }
  822. /**
  823. * Handles keyboard events specific to the menu.
  824. *
  825. * @param {Event} e
  826. */
  827. handleKeyboard(e) {
  828. const element = e.target;
  829. const cList = element.classList;
  830. const menuExpanded = this.element.getAttribute('aria-expanded');
  831. // Detect press on Escape
  832. if (e.key === 'Escape' || e.key === 'Esc') {
  833. if (document.activeElement === element) {
  834. element.blur();
  835. }
  836. if (menuExpanded === 'false') {
  837. this.closeOpenDropdown();
  838. }
  839. return;
  840. }
  841. // Handle Keyboard on the first panel
  842. if (cList.contains('ecl-mega-menu__info-link')) {
  843. if (e.key === 'ArrowUp') {
  844. if (this.isDesktop) {
  845. // Focus on the expanded nav item
  846. queryOne(
  847. '.ecl-mega-menu__item--expanded button',
  848. this.element,
  849. ).focus();
  850. } else if (this.back && !this.isDesktop) {
  851. // focus on the back button
  852. this.back.focus();
  853. }
  854. }
  855. if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
  856. // First item in the open dropdown.
  857. element.parentElement.parentElement.nextSibling.firstChild.firstChild.firstChild.focus();
  858. }
  859. }
  860. if (cList.contains('ecl-mega-menu__parent-link')) {
  861. if (e.key === 'ArrowUp') {
  862. const back = queryOne('.ecl-mega-menu__back', this.element);
  863. back.focus();
  864. return;
  865. }
  866. if (e.key === 'ArrowDown') {
  867. const mega = e.target.nextSibling;
  868. mega.firstElementChild.firstElementChild.firstChild.focus();
  869. return;
  870. }
  871. }
  872. // Handle keyboard on the see all links
  873. if (element.parentElement.classList.contains('ecl-mega-menu__see-all')) {
  874. if (e.key === 'ArrowUp') {
  875. // Focus on the last element of the sub-list
  876. element.parentElement.previousSibling.firstChild.focus();
  877. }
  878. if (e.key === 'ArrowDown') {
  879. // Focus on the fi
  880. const featured = element.parentElement.parentElement.nextSibling;
  881. if (featured) {
  882. const focusableSelectors = [
  883. 'a[href]',
  884. 'button:not([disabled])',
  885. 'input:not([disabled])',
  886. 'select:not([disabled])',
  887. 'textarea:not([disabled])',
  888. '[tabindex]:not([tabindex="-1"])',
  889. ];
  890. const focusableElements = queryAll(
  891. focusableSelectors.join(', '),
  892. featured,
  893. );
  894. if (focusableElements.length > 0) {
  895. focusableElements[0].focus();
  896. }
  897. }
  898. }
  899. }
  900. // Handle keyboard on the back button
  901. if (cList.contains('ecl-mega-menu__back')) {
  902. if (e.key === 'ArrowDown') {
  903. e.preventDefault();
  904. const expanded = queryOne(
  905. '[aria-expanded="true"]',
  906. element.parentElement.nextSibling,
  907. );
  908. // We have an opened list
  909. if (expanded) {
  910. const innerExpanded = queryOne(
  911. '.ecl-mega-menu__subitem--expanded',
  912. expanded.parentElement,
  913. );
  914. // We have an opened sub-list
  915. if (innerExpanded) {
  916. const parentLink = queryOne(
  917. '.ecl-mega-menu__parent-link',
  918. innerExpanded,
  919. );
  920. if (parentLink) {
  921. parentLink.focus();
  922. }
  923. } else {
  924. const infoLink = queryOne(
  925. '.ecl-mega-menu__info-link',
  926. expanded.parentElement,
  927. );
  928. if (infoLink) {
  929. infoLink.focus();
  930. } else {
  931. queryOne(
  932. '.ecl-mega-menu__subitem:first-child .ecl-mega-menu__sublink',
  933. expanded.parentElement,
  934. ).focus();
  935. }
  936. }
  937. }
  938. }
  939. if (e.key === 'ArrowUp') {
  940. // Focus on the open button
  941. this.open.focus();
  942. }
  943. }
  944. // Key actions to navigate between first level menu items
  945. if (cList.contains('ecl-mega-menu__link')) {
  946. if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
  947. e.preventDefault();
  948. let prevItem = element.previousSibling;
  949. if (prevItem && prevItem.classList.contains('ecl-mega-menu__link')) {
  950. prevItem.focus();
  951. return;
  952. }
  953. prevItem = element.parentElement.previousSibling;
  954. if (prevItem) {
  955. const prevLink = queryOne('.ecl-mega-menu__link', prevItem);
  956. if (prevLink) {
  957. prevLink.focus();
  958. return;
  959. }
  960. }
  961. }
  962. if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
  963. e.preventDefault();
  964. if (
  965. element.parentElement.getAttribute('aria-expanded') === 'true' &&
  966. e.key === 'ArrowDown'
  967. ) {
  968. const infoLink = queryOne(
  969. '.ecl-mega-menu__info-link',
  970. element.parentElement,
  971. );
  972. if (infoLink) {
  973. infoLink.focus();
  974. return;
  975. }
  976. }
  977. const nextItem = element.parentElement.nextSibling;
  978. if (nextItem) {
  979. const nextLink = queryOne('.ecl-mega-menu__link', nextItem);
  980. if (nextLink) {
  981. nextLink.focus();
  982. return;
  983. }
  984. }
  985. }
  986. }
  987. // Key actions to navigate between the sub-links
  988. if (cList.contains('ecl-mega-menu__sublink')) {
  989. if (e.key === 'ArrowDown') {
  990. e.preventDefault();
  991. const nextItem = element.parentElement.nextSibling;
  992. let nextLink = '';
  993. if (nextItem) {
  994. nextLink = queryOne('.ecl-mega-menu__sublink', nextItem);
  995. if (
  996. !nextLink &&
  997. nextItem.classList.contains('ecl-mega-menu__spacer')
  998. ) {
  999. nextLink = nextItem.nextSibling.firstElementChild;
  1000. }
  1001. if (nextLink) {
  1002. nextLink.focus();
  1003. return;
  1004. }
  1005. }
  1006. }
  1007. if (e.key === 'ArrowUp') {
  1008. e.preventDefault();
  1009. const prevItem = element.parentElement.previousSibling;
  1010. if (prevItem) {
  1011. const prevLink = queryOne('.ecl-mega-menu__sublink', prevItem);
  1012. if (prevLink) {
  1013. prevLink.focus();
  1014. }
  1015. } else {
  1016. const moreLink = queryOne(
  1017. '.ecl-mega-menu__info-link',
  1018. element.parentElement.parentElement.parentElement.previousSibling,
  1019. );
  1020. if (moreLink) {
  1021. moreLink.focus();
  1022. } else if (this.openPanel.num === 2) {
  1023. const parent = e.target.closest(
  1024. '.ecl-mega-menu__mega',
  1025. ).previousSibling;
  1026. if (parent) {
  1027. parent.focus();
  1028. }
  1029. } else if (this.back) {
  1030. this.back.focus();
  1031. }
  1032. }
  1033. }
  1034. }
  1035. if (e.key === 'ArrowRight') {
  1036. const expanded =
  1037. element.parentElement.getAttribute('aria-expanded') === 'true';
  1038. if (expanded) {
  1039. e.preventDefault();
  1040. // Focus on the first element in the second panel
  1041. element.nextSibling.firstElementChild.firstChild.firstChild.focus();
  1042. }
  1043. }
  1044. }
  1045. /**
  1046. * Handles global keyboard events, triggered outside of the menu.
  1047. *
  1048. * @param {Event} e
  1049. */
  1050. handleKeyboardGlobal(e) {
  1051. // Detect press on Escape
  1052. if (e.key === 'Escape' || e.key === 'Esc') {
  1053. if (this.isOpen) {
  1054. this.closeOpenDropdown(true);
  1055. }
  1056. }
  1057. }
  1058. /**
  1059. * Open menu list.
  1060. *
  1061. * @param {Event} e
  1062. *
  1063. * @fires MegaMenu#onOpen
  1064. */
  1065. handleClickOnOpen(e) {
  1066. if (this.isOpen) {
  1067. this.handleClickOnClose(e);
  1068. } else {
  1069. e.preventDefault();
  1070. this.disableScroll();
  1071. this.element.setAttribute('aria-expanded', 'true');
  1072. this.element.classList.add('ecl-mega-menu--start-panel');
  1073. this.element.classList.remove(
  1074. 'ecl-mega-menu--one-panel',
  1075. 'ecl-mega-menu--two-panels',
  1076. );
  1077. this.open.setAttribute('aria-expanded', 'true');
  1078. this.inner.setAttribute('aria-hidden', 'false');
  1079. this.isOpen = true;
  1080. if (this.header) {
  1081. this.header.classList.add(
  1082. 'ecl-site-header--open-menu',
  1083. 'ecl-site-header--open-menu-start',
  1084. );
  1085. }
  1086. // Update label
  1087. const closeLabel = this.element.getAttribute(this.labelCloseAttribute);
  1088. if (this.toggleLabel && closeLabel) {
  1089. this.toggleLabel.innerHTML = closeLabel;
  1090. }
  1091. this.positionMenuOverlay();
  1092. // Focus first element
  1093. if (this.links.length > 0) {
  1094. this.links[0].focus();
  1095. }
  1096. this.trigger('onOpen', e);
  1097. }
  1098. }
  1099. /**
  1100. * Close menu list.
  1101. *
  1102. * @param {Event} e
  1103. *
  1104. * @fires Menu#onClose
  1105. */
  1106. handleClickOnClose(e) {
  1107. if (this.element.getAttribute('aria-expanded') === 'true') {
  1108. this.focusTrap.deactivate();
  1109. this.closeOpenDropdown();
  1110. this.trigger('onClose', e);
  1111. } else {
  1112. this.handleClickOnOpen(e);
  1113. }
  1114. }
  1115. /**
  1116. * Toggle menu list.
  1117. *
  1118. * @param {Event} e
  1119. */
  1120. handleClickOnToggle(e) {
  1121. e.preventDefault();
  1122. if (this.isOpen) {
  1123. this.handleClickOnClose(e);
  1124. } else {
  1125. this.handleClickOnOpen(e);
  1126. }
  1127. }
  1128. /**
  1129. * Get back to previous list (on mobile)
  1130. *
  1131. * @fires MegaMenu#onBack
  1132. */
  1133. handleClickOnBack() {
  1134. const infoPanels = queryAll('.ecl-mega-menu__info', this.element);
  1135. infoPanels.forEach((info) => {
  1136. info.style.top = '';
  1137. });
  1138. const level2 = queryOne('.ecl-mega-menu__subitem--expanded', this.element);
  1139. if (level2) {
  1140. this.element.classList.remove(
  1141. 'ecl-mega-menu--two-panels',
  1142. 'ecl-mega-menu--start-panel',
  1143. );
  1144. this.element.classList.add('ecl-mega-menu--one-panel');
  1145. level2.setAttribute('aria-expanded', 'false');
  1146. level2.classList.remove(
  1147. 'ecl-mega-menu__subitem--expanded',
  1148. 'ecl-mega-menu__subitem--current',
  1149. );
  1150. const itemLink = queryOne(this.subLinkSelector, level2);
  1151. itemLink.setAttribute('aria-expanded', 'false');
  1152. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1153. const siblings = level2.parentElement.childNodes;
  1154. if (siblings) {
  1155. siblings.forEach((sibling) => {
  1156. sibling.style.display = '';
  1157. });
  1158. }
  1159. if (this.header) {
  1160. this.header.classList.remove('ecl-site-header--open-menu-start');
  1161. }
  1162. // Move the focus to the previously selected item
  1163. if (this.backItemLevel2) {
  1164. this.backItemLevel2.firstElementChild.focus();
  1165. }
  1166. this.openPanel.num = 1;
  1167. } else {
  1168. if (this.header) {
  1169. if (this.headerBanner) {
  1170. this.headerBanner.style.display = 'flex';
  1171. }
  1172. if (this.headerNotification) {
  1173. this.headerNotification.style.display = 'flex';
  1174. }
  1175. }
  1176. // Remove expanded class from inner menu
  1177. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  1178. this.element.classList.remove('ecl-mega-menu--one-panel');
  1179. // Remove css class and attribute from menu items
  1180. this.items.forEach((item) => {
  1181. item.classList.remove(
  1182. 'ecl-mega-menu__item--expanded',
  1183. 'ecl-mega-menu__item--current',
  1184. );
  1185. const itemLink = queryOne(this.linkSelector, item);
  1186. itemLink.setAttribute('aria-expanded', 'false');
  1187. });
  1188. // Move the focus to the previously selected item
  1189. if (this.backItemLevel1) {
  1190. this.backItemLevel1.firstElementChild.focus();
  1191. } else {
  1192. this.items[0].firstElementChild.focus();
  1193. }
  1194. this.openPanel.num = 0;
  1195. if (this.header) {
  1196. this.header.classList.add('ecl-site-header--open-menu-start');
  1197. }
  1198. this.positionMenuOverlay();
  1199. }
  1200. this.trigger('onBack', { level: level2 ? 2 : 1 });
  1201. }
  1202. /**
  1203. * Show/hide the first panel
  1204. *
  1205. * @param {Node} menuItem
  1206. * @param {string} op (expand or collapse)
  1207. *
  1208. * @fires MegaMenu#onOpenPanel
  1209. */
  1210. handleFirstPanel(menuItem, op) {
  1211. switch (op) {
  1212. case 'expand': {
  1213. this.inner.classList.add('ecl-mega-menu__inner--expanded');
  1214. this.positionMenuOverlay();
  1215. this.checkDropdownHeight(menuItem);
  1216. this.element.setAttribute('data-expanded', true);
  1217. this.element.setAttribute('aria-expanded', 'true');
  1218. this.element.classList.add('ecl-mega-menu--one-panel');
  1219. this.element.classList.remove('ecl-mega-menu--start-panel');
  1220. this.open.setAttribute('aria-expanded', 'true');
  1221. if (this.header) {
  1222. this.header.classList.add('ecl-site-header--open-menu');
  1223. this.header.classList.remove('ecl-site-header--open-menu-start');
  1224. if (!this.isDesktop) {
  1225. if (this.headerBanner) {
  1226. this.headerBanner.style.display = 'none';
  1227. }
  1228. if (this.headerNotification) {
  1229. this.headerNotification.style.display = 'none';
  1230. }
  1231. }
  1232. }
  1233. this.disableScroll();
  1234. this.isOpen = true;
  1235. this.items.forEach((item) => {
  1236. const itemLink = queryOne(this.linkSelector, item);
  1237. if (itemLink && itemLink.hasAttribute('aria-expanded')) {
  1238. if (item === menuItem) {
  1239. item.classList.add(
  1240. 'ecl-mega-menu__item--expanded',
  1241. 'ecl-mega-menu__item--current',
  1242. );
  1243. itemLink.setAttribute('aria-expanded', 'true');
  1244. this.backItemLevel1 = item;
  1245. } else {
  1246. itemLink.setAttribute('aria-expanded', 'false');
  1247. item.classList.remove(
  1248. 'ecl-mega-menu__item--current',
  1249. 'ecl-mega-menu__item--expanded',
  1250. );
  1251. }
  1252. }
  1253. });
  1254. if (!this.isDesktop && this.back) {
  1255. this.back.focus();
  1256. }
  1257. this.openPanel = {
  1258. num: 1,
  1259. item: menuItem,
  1260. };
  1261. const details = { panel: 1, item: menuItem };
  1262. this.trigger('OnOpenPanel', details);
  1263. if (this.isDesktop) {
  1264. const list = queryOne('.ecl-mega-menu__sublist', menuItem);
  1265. if (list) {
  1266. // Expand the item in the sublist if it contains children.
  1267. const firstExpandedChild = Array.from(list.children).find((child) =>
  1268. child.firstElementChild?.hasAttribute('aria-expanded'),
  1269. );
  1270. if (firstExpandedChild) {
  1271. this.handleSecondPanel(firstExpandedChild, 'expand');
  1272. }
  1273. }
  1274. }
  1275. break;
  1276. }
  1277. case 'collapse':
  1278. this.closeOpenDropdown();
  1279. break;
  1280. default:
  1281. }
  1282. }
  1283. /**
  1284. * Show/hide the second panel
  1285. *
  1286. * @param {Node} menuItem
  1287. * @param {string} op (expand or collapse)
  1288. *
  1289. * @fires MegaMenu#onOpenPanel
  1290. */
  1291. handleSecondPanel(menuItem, op) {
  1292. const infoPanel = queryOne(
  1293. '.ecl-mega-menu__info',
  1294. menuItem.closest('.ecl-container'),
  1295. );
  1296. let siblings;
  1297. switch (op) {
  1298. case 'expand': {
  1299. this.element.classList.remove(
  1300. 'ecl-mega-menu--one-panel',
  1301. 'ecl-mega-menu--start-panel',
  1302. );
  1303. this.element.classList.add('ecl-mega-menu--two-panels');
  1304. this.subItems.forEach((item) => {
  1305. const itemLink = queryOne(this.subLinkSelector, item);
  1306. if (item === menuItem) {
  1307. if (itemLink && itemLink.hasAttribute('aria-expanded')) {
  1308. itemLink.setAttribute('aria-expanded', 'true');
  1309. if (!this.isDesktop) {
  1310. // We use this class mainly to recover the default behavior of the link.
  1311. itemLink.classList.add('ecl-mega-menu__parent-link');
  1312. if (this.back) {
  1313. this.back.focus();
  1314. }
  1315. }
  1316. item.classList.add('ecl-mega-menu__subitem--expanded');
  1317. }
  1318. item.classList.add('ecl-mega-menu__subitem--current');
  1319. this.backItemLevel2 = item;
  1320. } else {
  1321. if (itemLink && itemLink.hasAttribute('aria-expanded')) {
  1322. itemLink.setAttribute('aria-expanded', 'false');
  1323. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1324. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1325. }
  1326. item.classList.remove('ecl-mega-menu__subitem--current');
  1327. }
  1328. });
  1329. this.openPanel = { num: 2, item: menuItem };
  1330. siblings = menuItem.parentNode.childNodes;
  1331. if (this.isDesktop) {
  1332. // Reset style for the siblings, in case they were hidden
  1333. siblings.forEach((sibling) => {
  1334. if (sibling !== menuItem) {
  1335. sibling.style.display = '';
  1336. }
  1337. });
  1338. } else {
  1339. // Hide other items in the sublist
  1340. siblings.forEach((sibling) => {
  1341. if (sibling !== menuItem) {
  1342. sibling.style.display = 'none';
  1343. }
  1344. });
  1345. }
  1346. this.positionMenuOverlay();
  1347. const details = { panel: 2, item: menuItem };
  1348. this.trigger('OnOpenPanel', details);
  1349. break;
  1350. }
  1351. case 'collapse':
  1352. this.element.classList.remove('ecl-mega-menu--two-panels');
  1353. this.openPanel = { num: 1 };
  1354. // eslint-disable-next-line no-case-declarations
  1355. const itemLink = queryOne(this.subLinkSelector, menuItem);
  1356. itemLink.setAttribute('aria-expanded', 'false');
  1357. menuItem.classList.remove(
  1358. 'ecl-mega-menu__subitem--expanded',
  1359. 'ecl-mega-menu__subitem--current',
  1360. );
  1361. if (infoPanel) {
  1362. infoPanel.style.top = '';
  1363. }
  1364. break;
  1365. default:
  1366. }
  1367. }
  1368. /**
  1369. * Click on a menu item
  1370. *
  1371. * @param {Event} e
  1372. *
  1373. * @fires MegaMenu#onItemClick
  1374. */
  1375. handleClickOnItem(e) {
  1376. let isInTheContainer = false;
  1377. const menuItem = e.target.closest('li');
  1378. const container = queryOne(
  1379. '.ecl-mega-menu__mega-container-scrollable',
  1380. menuItem,
  1381. );
  1382. if (container) {
  1383. isInTheContainer = container.contains(e.target);
  1384. }
  1385. // We need to ensure that the click doesn't come from a parent link
  1386. // or from an open container, in that case we do not act.
  1387. if (
  1388. !e.target.classList.contains(
  1389. 'ecl-mega-menu__mega-container-scrollable',
  1390. ) &&
  1391. !isInTheContainer
  1392. ) {
  1393. this.trigger('onItemClick', { item: menuItem, event: e });
  1394. const hasChildren =
  1395. menuItem.firstElementChild.getAttribute('aria-expanded');
  1396. if (hasChildren && menuItem.classList.contains('ecl-mega-menu__item')) {
  1397. e.preventDefault();
  1398. e.stopPropagation();
  1399. if (!this.isDesktop) {
  1400. this.handleFirstPanel(menuItem, 'expand');
  1401. } else {
  1402. const isOpen = hasChildren === 'true';
  1403. if (isOpen) {
  1404. this.handleFirstPanel(menuItem, 'collapse');
  1405. } else {
  1406. this.closeOpenDropdown();
  1407. this.handleFirstPanel(menuItem, 'expand');
  1408. }
  1409. }
  1410. }
  1411. }
  1412. }
  1413. /**
  1414. * Click on a subitem
  1415. *
  1416. * @param {Event} e
  1417. */
  1418. handleClickOnSubitem(e) {
  1419. const menuItem = e.target.closest(this.subItemSelector);
  1420. if (menuItem && menuItem.firstElementChild.hasAttribute('aria-expanded')) {
  1421. const parentLink = queryOne('.ecl-mega-menu__parent-link', menuItem);
  1422. if (parentLink) {
  1423. return;
  1424. }
  1425. e.preventDefault();
  1426. e.stopPropagation();
  1427. const isExpanded =
  1428. menuItem.firstElementChild.getAttribute('aria-expanded') === 'true';
  1429. if (isExpanded) {
  1430. this.handleSecondPanel(menuItem, 'collapse');
  1431. } else {
  1432. this.handleSecondPanel(menuItem, 'expand');
  1433. }
  1434. }
  1435. }
  1436. /**
  1437. * Deselect any opened menu item
  1438. *
  1439. * @param {boolean} esc, whether the call was originated by a press on Esc
  1440. *
  1441. * @fires MegaMenu#onFocusTrapToggle
  1442. */
  1443. closeOpenDropdown(esc = false) {
  1444. if (this.header) {
  1445. this.header.classList.remove(
  1446. 'ecl-site-header--open-menu',
  1447. 'ecl-site-header--open-menu-start',
  1448. );
  1449. if (this.headerBanner) {
  1450. this.headerBanner.style.display = 'flex';
  1451. }
  1452. if (this.headerNotification) {
  1453. this.headerNotification.style.display = 'flex';
  1454. }
  1455. }
  1456. this.enableScroll();
  1457. this.element.setAttribute('aria-expanded', 'false');
  1458. this.element.removeAttribute('data-expanded');
  1459. this.element.classList.remove(
  1460. 'ecl-mega-menu--start-panel',
  1461. 'ecl-mega-menu--two-panels',
  1462. 'ecl-mega-menu--one-panel',
  1463. );
  1464. this.open.setAttribute('aria-expanded', 'false');
  1465. // Remove css class and attribute from inner menu
  1466. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  1467. // Reset heights
  1468. const megaMenus = queryAll('[data-ecl-mega-menu-mega]', this.element);
  1469. megaMenus.forEach((mega) => {
  1470. mega.style.height = '';
  1471. mega.style.top = '';
  1472. mega.style.opacity = '';
  1473. });
  1474. if (this.wrappers) {
  1475. this.wrappers.forEach((wrapper) => {
  1476. wrapper.style = '';
  1477. });
  1478. }
  1479. let currentItem = false;
  1480. // Remove css class and attribute from menu items
  1481. this.items.forEach((item) => {
  1482. item.classList.remove('ecl-mega-menu__item--current');
  1483. const itemLink = queryOne(this.linkSelector, item);
  1484. if (itemLink.getAttribute('aria-expanded') === 'true') {
  1485. item.classList.remove('ecl-mega-menu__item--expanded');
  1486. itemLink.setAttribute('aria-expanded', 'false');
  1487. currentItem = itemLink;
  1488. }
  1489. });
  1490. // Remove css class and attribute from menu subitems
  1491. this.subItems.forEach((item) => {
  1492. item.classList.remove('ecl-mega-menu__subitem--current');
  1493. item.style.display = '';
  1494. const itemLink = queryOne(this.subLinkSelector, item);
  1495. if (itemLink && itemLink.hasAttribute('aria-expanded')) {
  1496. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1497. item.style.display = '';
  1498. itemLink.setAttribute('aria-expanded', 'false');
  1499. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1500. }
  1501. });
  1502. // Remove styles set for the sublists
  1503. const sublists = queryAll('.ecl-mega-menu__sublist');
  1504. if (sublists) {
  1505. sublists.forEach((sublist) => {
  1506. sublist.classList.remove(
  1507. 'ecl-mega-menu__sublist--no-border',
  1508. '.ecl-mega-menu__sublist--scrollable',
  1509. );
  1510. });
  1511. }
  1512. // Update label
  1513. const openLabel = this.element.getAttribute(this.labelOpenAttribute);
  1514. if (this.toggleLabel && openLabel) {
  1515. this.toggleLabel.innerHTML = openLabel;
  1516. }
  1517. this.openPanel = {
  1518. num: 0,
  1519. item: false,
  1520. };
  1521. // If the focus trap is active, deactivate it
  1522. this.focusTrap.deactivate();
  1523. // Focus on the open button in mobile or on the formerly expanded item in desktop.
  1524. if (!this.isDesktop && this.open && esc) {
  1525. this.open.focus();
  1526. } else if (this.isDesktop && currentItem && esc) {
  1527. currentItem.focus();
  1528. }
  1529. this.trigger('onFocusTrapToggle', { active: false });
  1530. this.isOpen = false;
  1531. }
  1532. /**
  1533. * Focus out of a menu link
  1534. *
  1535. * @param {Event} e
  1536. *
  1537. * @fires MegaMenu#onFocusTrapToggle
  1538. */
  1539. handleFocusOut(e) {
  1540. const element = e.target;
  1541. const menuExpanded = this.element.getAttribute('aria-expanded');
  1542. // Specific focus action for mobile menu
  1543. // Loop through the items and go back to close button
  1544. if (menuExpanded === 'true' && !this.isDesktop) {
  1545. const nextItem = element.parentElement.nextSibling;
  1546. if (!nextItem) {
  1547. const nextFocusTarget = e.relatedTarget;
  1548. if (!this.element.contains(nextFocusTarget)) {
  1549. // This is the last item, go back to close button
  1550. this.focusTrap.activate();
  1551. this.trigger('onFocusTrapToggle', {
  1552. active: true,
  1553. lastFocusedEl: element.parentElement,
  1554. });
  1555. }
  1556. }
  1557. }
  1558. }
  1559. /**
  1560. * Handles global click events, triggered outside of the menu.
  1561. *
  1562. * @param {Event} e
  1563. */
  1564. handleClickGlobal(e) {
  1565. if (
  1566. !e.target.classList.contains(
  1567. 'ecl-mega-menu__mega-container-scrollable',
  1568. ) &&
  1569. (e.target.classList.contains('ecl-mega-menu__overlay') ||
  1570. !this.element.contains(e.target)) &&
  1571. this.isOpen
  1572. ) {
  1573. this.closeOpenDropdown();
  1574. }
  1575. }
  1576. }
  1577. export default MegaMenu;