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