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. if (infoPanel) {
  625. infoPanelHeight = infoPanel.scrollHeight + 16;
  626. }
  627. if (infoPanel && this.isLarge) {
  628. heights.push(infoPanelHeight);
  629. } else if (infoPanel && this.isDesktop) {
  630. itemsHeight = infoPanelHeight;
  631. subItemsHeight = infoPanelHeight;
  632. }
  633. if (mainPanel) {
  634. const mainTop = mainPanel.getBoundingClientRect().top;
  635. const list = queryOne('.ecl-mega-menu__sublist', mainPanel);
  636. if (!list) {
  637. const isContainer = menuItem.classList.contains(
  638. 'ecl-mega-menu__item--has-container',
  639. );
  640. if (isContainer) {
  641. const container = queryOne(
  642. '.ecl-mega-menu__mega-container',
  643. menuItem,
  644. );
  645. if (container) {
  646. container.firstElementChild.style.height = `${viewportHeight - mainTop}px`;
  647. return;
  648. }
  649. }
  650. } else {
  651. const items = list.children;
  652. if (items.length > 0) {
  653. Array.from(items).forEach((item) => {
  654. itemsHeight += item.getBoundingClientRect().height;
  655. });
  656. heights.push(itemsHeight);
  657. }
  658. }
  659. }
  660. const expanded = queryOne(
  661. '.ecl-mega-menu__subitem--expanded',
  662. menuItem,
  663. );
  664. if (expanded) {
  665. secondPanel = queryOne('.ecl-mega-menu__mega--level-2', expanded);
  666. if (secondPanel) {
  667. const subItems = queryAll(`${this.subItemSelector} a`, secondPanel);
  668. if (subItems.length > 0) {
  669. subItems.forEach((item) => {
  670. subItemsHeight += item.getBoundingClientRect().height;
  671. });
  672. }
  673. heights.push(subItemsHeight);
  674. featuredPanel = queryOne('.ecl-mega-menu__featured', expanded);
  675. if (featuredPanel) {
  676. heights.push(featuredPanel.scrollHeight);
  677. }
  678. }
  679. }
  680. const maxHeight = Math.max(...heights);
  681. const containerBounding = this.inner.getBoundingClientRect();
  682. const containerBottom = containerBounding.bottom;
  683. // By requirements, limit the height to the 70% of the available space.
  684. const availableHeight = (window.innerHeight - containerBottom) * 0.7;
  685. if (maxHeight > availableHeight) {
  686. height = availableHeight;
  687. } else {
  688. height = maxHeight;
  689. }
  690. const wrapper = queryOne('.ecl-mega-menu__wrapper', menuItem);
  691. if (wrapper) {
  692. wrapper.style.height = `${height}px`;
  693. }
  694. if (mainPanel && this.isLarge) {
  695. mainPanel.style.height = `${height}px`;
  696. } else if (mainPanel && infoPanel && this.isDesktop) {
  697. mainPanel.style.height = `${height - infoPanelHeight}px`;
  698. }
  699. if (infoPanel && this.isLarge) {
  700. infoPanel.style.height = `${height}px`;
  701. }
  702. if (secondPanel && this.isLarge) {
  703. secondPanel.style.height = `${height}px`;
  704. } else if (secondPanel && this.isDesktop) {
  705. secondPanel.style.height = `${height - infoPanelHeight}px`;
  706. }
  707. if (featuredPanel && this.isLarge) {
  708. featuredPanel.style.height = `${height}px`;
  709. } else if (featuredPanel && this.isDesktop) {
  710. featuredPanel.style.height = `${height - infoPanelHeight}px`;
  711. }
  712. }
  713. if (mainPanel && this.isDesktop) {
  714. mainPanel.style.opacity = 1;
  715. }
  716. if (infoPanel && this.isDesktop) {
  717. infoPanel.style.opacity = 1;
  718. }
  719. }, 100);
  720. }
  721. /**
  722. * Dinamically set the position of the menu overlay
  723. */
  724. positionMenuOverlay() {
  725. let availableHeight = 0;
  726. if (!this.isDesktop) {
  727. // In mobile, we get the bottom position of the site header header
  728. setTimeout(() => {
  729. if (this.header) {
  730. const position = this.header.getBoundingClientRect();
  731. const bottomPosition = Math.round(position.bottom);
  732. if (this.menuOverlay) {
  733. this.menuOverlay.style.top = `${bottomPosition}px`;
  734. }
  735. if (this.inner) {
  736. this.inner.style.top = `${bottomPosition}px`;
  737. }
  738. const item = queryOne('.ecl-mega-menu__item--expanded', this.element);
  739. if (item) {
  740. const subList = queryOne('.ecl-mega-menu__sublist', item);
  741. if (subList && this.openPanel.num === 1) {
  742. const info = queryOne('.ecl-mega-menu__info', item);
  743. if (info) {
  744. const bottomRect = info.getBoundingClientRect();
  745. const bottomInfo = bottomRect.bottom;
  746. availableHeight = window.innerHeight - bottomInfo - 16;
  747. subList.classList.add('ecl-mega-menu__sublist--scrollable');
  748. subList.style.height = `${availableHeight}px`;
  749. }
  750. } else if (subList) {
  751. subList.classList.remove('ecl-mega-menu__sublist--scrollable');
  752. subList.style.height = '';
  753. }
  754. }
  755. if (this.openPanel.num === 2) {
  756. const subItem = queryOne(
  757. '.ecl-mega-menu__subitem--expanded',
  758. this.element,
  759. );
  760. if (subItem) {
  761. const subMega = queryOne(
  762. '.ecl-mega-menu__mega--level-2',
  763. subItem,
  764. );
  765. if (subMega) {
  766. const subMegaRect = subMega.getBoundingClientRect();
  767. const subMegaTop = subMegaRect.top;
  768. availableHeight = window.innerHeight - subMegaTop;
  769. subMega.style.height = `${availableHeight}px`;
  770. }
  771. }
  772. }
  773. if (this.wrappers) {
  774. this.wrappers.forEach((wrapper) => {
  775. wrapper.style.top = '';
  776. wrapper.style.height = '';
  777. });
  778. }
  779. }
  780. }, 0);
  781. } else {
  782. setTimeout(() => {
  783. // In desktop we get the bottom position of the whole site header
  784. const siteHeader = queryOne('.ecl-site-header', document);
  785. if (siteHeader) {
  786. const headerRect = siteHeader.getBoundingClientRect();
  787. const headerBottom = headerRect.bottom;
  788. const item = queryOne(this.itemSelector, this.element);
  789. const rect = item.getBoundingClientRect();
  790. const rectHeight = rect.height;
  791. if (this.wrappers) {
  792. this.wrappers.forEach((wrapper) => {
  793. wrapper.style.top = `${rectHeight}px`;
  794. });
  795. }
  796. if (this.menuOverlay) {
  797. this.menuOverlay.style.top = `${headerBottom}px`;
  798. }
  799. } else {
  800. const bottomPosition = this.element.getBoundingClientRect().bottom;
  801. if (this.menuOverlay) {
  802. this.menuOverlay.style.top = `${bottomPosition}px`;
  803. }
  804. }
  805. }, 0);
  806. }
  807. }
  808. /**
  809. * Handles keyboard events specific to the menu.
  810. *
  811. * @param {Event} e
  812. */
  813. handleKeyboard(e) {
  814. const element = e.target;
  815. const cList = element.classList;
  816. const menuExpanded = this.element.getAttribute('aria-expanded');
  817. // Detect press on Escape
  818. if (e.key === 'Escape' || e.key === 'Esc') {
  819. if (document.activeElement === element) {
  820. element.blur();
  821. }
  822. if (menuExpanded === 'false') {
  823. this.closeOpenDropdown();
  824. }
  825. return;
  826. }
  827. // Handle Keyboard on the first panel
  828. if (cList.contains('ecl-mega-menu__info-link')) {
  829. if (e.key === 'ArrowUp') {
  830. if (this.isDesktop) {
  831. // Focus on the expanded nav item
  832. queryOne(
  833. '.ecl-mega-menu__item--expanded button',
  834. this.element,
  835. ).focus();
  836. } else if (this.back && !this.isDesktop) {
  837. // focus on the back button
  838. this.back.focus();
  839. }
  840. }
  841. if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
  842. // First item in the open dropdown.
  843. element.parentElement.parentElement.nextSibling.firstChild.firstChild.firstChild.focus();
  844. }
  845. }
  846. if (cList.contains('ecl-mega-menu__parent-link')) {
  847. if (e.key === 'ArrowUp') {
  848. const back = queryOne('.ecl-mega-menu__back', this.element);
  849. back.focus();
  850. return;
  851. }
  852. if (e.key === 'ArrowDown') {
  853. const mega = e.target.nextSibling;
  854. mega.firstElementChild.firstElementChild.firstChild.focus();
  855. return;
  856. }
  857. }
  858. // Handle keyboard on the see all links
  859. if (element.parentElement.classList.contains('ecl-mega-menu__see-all')) {
  860. if (e.key === 'ArrowUp') {
  861. // Focus on the last element of the sub-list
  862. element.parentElement.previousSibling.firstChild.focus();
  863. }
  864. if (e.key === 'ArrowDown') {
  865. // Focus on the fi
  866. const featured = element.parentElement.parentElement.nextSibling;
  867. if (featured) {
  868. const focusableSelectors = [
  869. 'a[href]',
  870. 'button:not([disabled])',
  871. 'input:not([disabled])',
  872. 'select:not([disabled])',
  873. 'textarea:not([disabled])',
  874. '[tabindex]:not([tabindex="-1"])',
  875. ];
  876. const focusableElements = queryAll(
  877. focusableSelectors.join(', '),
  878. featured,
  879. );
  880. if (focusableElements.length > 0) {
  881. focusableElements[0].focus();
  882. }
  883. }
  884. }
  885. }
  886. // Handle keyboard on the back button
  887. if (cList.contains('ecl-mega-menu__back')) {
  888. if (e.key === 'ArrowDown') {
  889. e.preventDefault();
  890. const expanded = queryOne(
  891. '[aria-expanded="true"]',
  892. element.parentElement.nextSibling,
  893. );
  894. // We have an opened list
  895. if (expanded) {
  896. const innerExpanded = queryOne(
  897. '.ecl-mega-menu__subitem--expanded',
  898. expanded.parentElement,
  899. );
  900. // We have an opened sub-list
  901. if (innerExpanded) {
  902. const parentLink = queryOne(
  903. '.ecl-mega-menu__parent-link',
  904. innerExpanded,
  905. );
  906. if (parentLink) {
  907. parentLink.focus();
  908. }
  909. } else {
  910. const infoLink = queryOne(
  911. '.ecl-mega-menu__info-link',
  912. expanded.parentElement,
  913. );
  914. if (infoLink) {
  915. infoLink.focus();
  916. } else {
  917. queryOne(
  918. '.ecl-mega-menu__subitem:first-child .ecl-mega-menu__sublink',
  919. expanded.parentElement,
  920. ).focus();
  921. }
  922. }
  923. }
  924. }
  925. if (e.key === 'ArrowUp') {
  926. // Focus on the open button
  927. this.open.focus();
  928. }
  929. }
  930. // Key actions to navigate between first level menu items
  931. if (cList.contains('ecl-mega-menu__link')) {
  932. if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
  933. e.preventDefault();
  934. let prevItem = element.previousSibling;
  935. if (prevItem && prevItem.classList.contains('ecl-mega-menu__link')) {
  936. prevItem.focus();
  937. return;
  938. }
  939. prevItem = element.parentElement.previousSibling;
  940. if (prevItem) {
  941. const prevLink = queryOne('.ecl-mega-menu__link', prevItem);
  942. if (prevLink) {
  943. prevLink.focus();
  944. return;
  945. }
  946. }
  947. }
  948. if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
  949. e.preventDefault();
  950. if (
  951. element.parentElement.getAttribute('aria-expanded') === 'true' &&
  952. e.key === 'ArrowDown'
  953. ) {
  954. const infoLink = queryOne(
  955. '.ecl-mega-menu__info-link',
  956. element.parentElement,
  957. );
  958. if (infoLink) {
  959. infoLink.focus();
  960. return;
  961. }
  962. }
  963. const nextItem = element.parentElement.nextSibling;
  964. if (nextItem) {
  965. const nextLink = queryOne('.ecl-mega-menu__link', nextItem);
  966. if (nextLink) {
  967. nextLink.focus();
  968. return;
  969. }
  970. }
  971. }
  972. }
  973. // Key actions to navigate between the sub-links
  974. if (cList.contains('ecl-mega-menu__sublink')) {
  975. if (e.key === 'ArrowDown') {
  976. e.preventDefault();
  977. const nextItem = element.parentElement.nextSibling;
  978. let nextLink = '';
  979. if (nextItem) {
  980. nextLink = queryOne('.ecl-mega-menu__sublink', nextItem);
  981. if (
  982. !nextLink &&
  983. nextItem.classList.contains('ecl-mega-menu__spacer')
  984. ) {
  985. nextLink = nextItem.nextSibling.firstElementChild;
  986. }
  987. if (nextLink) {
  988. nextLink.focus();
  989. return;
  990. }
  991. }
  992. }
  993. if (e.key === 'ArrowUp') {
  994. e.preventDefault();
  995. const prevItem = element.parentElement.previousSibling;
  996. if (prevItem) {
  997. const prevLink = queryOne('.ecl-mega-menu__sublink', prevItem);
  998. if (prevLink) {
  999. prevLink.focus();
  1000. }
  1001. } else {
  1002. const moreLink = queryOne(
  1003. '.ecl-mega-menu__info-link',
  1004. element.parentElement.parentElement.parentElement.previousSibling,
  1005. );
  1006. if (moreLink) {
  1007. moreLink.focus();
  1008. } else if (this.openPanel.num === 2) {
  1009. const parent = e.target.closest(
  1010. '.ecl-mega-menu__mega',
  1011. ).previousSibling;
  1012. if (parent) {
  1013. parent.focus();
  1014. }
  1015. } else if (this.back) {
  1016. this.back.focus();
  1017. }
  1018. }
  1019. }
  1020. }
  1021. if (e.key === 'ArrowRight') {
  1022. const expanded =
  1023. element.parentElement.getAttribute('aria-expanded') === 'true';
  1024. if (expanded) {
  1025. e.preventDefault();
  1026. // Focus on the first element in the second panel
  1027. element.nextSibling.firstElementChild.firstChild.firstChild.focus();
  1028. }
  1029. }
  1030. }
  1031. /**
  1032. * Handles global keyboard events, triggered outside of the menu.
  1033. *
  1034. * @param {Event} e
  1035. */
  1036. handleKeyboardGlobal(e) {
  1037. // Detect press on Escape
  1038. if (e.key === 'Escape' || e.key === 'Esc') {
  1039. if (this.isOpen) {
  1040. this.closeOpenDropdown(true);
  1041. }
  1042. }
  1043. }
  1044. /**
  1045. * Open menu list.
  1046. *
  1047. * @param {Event} e
  1048. *
  1049. * @fires MegaMenu#onOpen
  1050. */
  1051. handleClickOnOpen(e) {
  1052. if (this.isOpen) {
  1053. this.handleClickOnClose(e);
  1054. } else {
  1055. e.preventDefault();
  1056. this.disableScroll();
  1057. this.element.setAttribute('aria-expanded', 'true');
  1058. this.element.classList.add('ecl-mega-menu--start-panel');
  1059. this.element.classList.remove(
  1060. 'ecl-mega-menu--one-panel',
  1061. 'ecl-mega-menu--two-panels',
  1062. );
  1063. this.open.setAttribute('aria-expanded', 'true');
  1064. this.inner.setAttribute('aria-hidden', 'false');
  1065. this.isOpen = true;
  1066. if (this.header) {
  1067. this.header.classList.add(
  1068. 'ecl-site-header--open-menu',
  1069. 'ecl-site-header--open-menu-start',
  1070. );
  1071. }
  1072. // Update label
  1073. const closeLabel = this.element.getAttribute(this.labelCloseAttribute);
  1074. if (this.toggleLabel && closeLabel) {
  1075. this.toggleLabel.innerHTML = closeLabel;
  1076. }
  1077. this.positionMenuOverlay();
  1078. // Focus first element
  1079. if (this.links.length > 0) {
  1080. this.links[0].focus();
  1081. }
  1082. this.trigger('onOpen', e);
  1083. }
  1084. }
  1085. /**
  1086. * Close menu list.
  1087. *
  1088. * @param {Event} e
  1089. *
  1090. * @fires Menu#onClose
  1091. */
  1092. handleClickOnClose(e) {
  1093. if (this.element.getAttribute('aria-expanded') === 'true') {
  1094. this.focusTrap.deactivate();
  1095. this.closeOpenDropdown();
  1096. this.trigger('onClose', e);
  1097. } else {
  1098. this.handleClickOnOpen(e);
  1099. }
  1100. }
  1101. /**
  1102. * Toggle menu list.
  1103. *
  1104. * @param {Event} e
  1105. */
  1106. handleClickOnToggle(e) {
  1107. e.preventDefault();
  1108. if (this.isOpen) {
  1109. this.handleClickOnClose(e);
  1110. } else {
  1111. this.handleClickOnOpen(e);
  1112. }
  1113. }
  1114. /**
  1115. * Get back to previous list (on mobile)
  1116. *
  1117. * @fires MegaMenu#onBack
  1118. */
  1119. handleClickOnBack() {
  1120. const infoPanels = queryAll('.ecl-mega-menu__info', this.element);
  1121. infoPanels.forEach((info) => {
  1122. info.style.top = '';
  1123. });
  1124. const level2 = queryOne('.ecl-mega-menu__subitem--expanded', this.element);
  1125. if (level2) {
  1126. this.element.classList.remove(
  1127. 'ecl-mega-menu--two-panels',
  1128. 'ecl-mega-menu--start-panel',
  1129. );
  1130. this.element.classList.add('ecl-mega-menu--one-panel');
  1131. level2.setAttribute('aria-expanded', 'false');
  1132. level2.classList.remove(
  1133. 'ecl-mega-menu__subitem--expanded',
  1134. 'ecl-mega-menu__subitem--current',
  1135. );
  1136. const itemLink = queryOne(this.subLinkSelector, level2);
  1137. itemLink.setAttribute('aria-expanded', 'false');
  1138. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1139. const siblings = level2.parentElement.childNodes;
  1140. if (siblings) {
  1141. siblings.forEach((sibling) => {
  1142. sibling.style.display = '';
  1143. });
  1144. }
  1145. if (this.header) {
  1146. this.header.classList.remove('ecl-site-header--open-menu-start');
  1147. }
  1148. // Move the focus to the previously selected item
  1149. if (this.backItemLevel2) {
  1150. this.backItemLevel2.firstElementChild.focus();
  1151. }
  1152. this.openPanel.num = 1;
  1153. } else {
  1154. if (this.header) {
  1155. if (this.headerBanner) {
  1156. this.headerBanner.style.display = 'flex';
  1157. }
  1158. if (this.headerNotification) {
  1159. this.headerNotification.style.display = 'flex';
  1160. }
  1161. }
  1162. // Remove expanded class from inner menu
  1163. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  1164. this.element.classList.remove('ecl-mega-menu--one-panel');
  1165. // Remove css class and attribute from menu items
  1166. this.items.forEach((item) => {
  1167. item.classList.remove(
  1168. 'ecl-mega-menu__item--expanded',
  1169. 'ecl-mega-menu__item--current',
  1170. );
  1171. const itemLink = queryOne(this.linkSelector, item);
  1172. itemLink.setAttribute('aria-expanded', 'false');
  1173. });
  1174. // Move the focus to the previously selected item
  1175. if (this.backItemLevel1) {
  1176. this.backItemLevel1.firstElementChild.focus();
  1177. } else {
  1178. this.items[0].firstElementChild.focus();
  1179. }
  1180. this.openPanel.num = 0;
  1181. if (this.header) {
  1182. this.header.classList.add('ecl-site-header--open-menu-start');
  1183. }
  1184. this.positionMenuOverlay();
  1185. }
  1186. this.trigger('onBack', { level: level2 ? 2 : 1 });
  1187. }
  1188. /**
  1189. * Show/hide the first panel
  1190. *
  1191. * @param {Node} menuItem
  1192. * @param {string} op (expand or collapse)
  1193. *
  1194. * @fires MegaMenu#onOpenPanel
  1195. */
  1196. handleFirstPanel(menuItem, op) {
  1197. switch (op) {
  1198. case 'expand': {
  1199. this.inner.classList.add('ecl-mega-menu__inner--expanded');
  1200. this.positionMenuOverlay();
  1201. this.checkDropdownHeight(menuItem);
  1202. this.element.setAttribute('data-expanded', true);
  1203. this.element.setAttribute('aria-expanded', 'true');
  1204. this.element.classList.add('ecl-mega-menu--one-panel');
  1205. this.element.classList.remove('ecl-mega-menu--start-panel');
  1206. this.open.setAttribute('aria-expanded', 'true');
  1207. if (this.header) {
  1208. this.header.classList.add('ecl-site-header--open-menu');
  1209. this.header.classList.remove('ecl-site-header--open-menu-start');
  1210. if (!this.isDesktop) {
  1211. if (this.headerBanner) {
  1212. this.headerBanner.style.display = 'none';
  1213. }
  1214. if (this.headerNotification) {
  1215. this.headerNotification.style.display = 'none';
  1216. }
  1217. }
  1218. }
  1219. this.disableScroll();
  1220. this.isOpen = true;
  1221. this.items.forEach((item) => {
  1222. const itemLink = queryOne(this.linkSelector, item);
  1223. if (itemLink.hasAttribute('aria-expanded')) {
  1224. if (item === menuItem) {
  1225. item.classList.add(
  1226. 'ecl-mega-menu__item--expanded',
  1227. 'ecl-mega-menu__item--current',
  1228. );
  1229. itemLink.setAttribute('aria-expanded', 'true');
  1230. this.backItemLevel1 = item;
  1231. } else {
  1232. itemLink.setAttribute('aria-expanded', 'false');
  1233. item.classList.remove(
  1234. 'ecl-mega-menu__item--current',
  1235. 'ecl-mega-menu__item--expanded',
  1236. );
  1237. }
  1238. }
  1239. });
  1240. if (!this.isDesktop && this.back) {
  1241. this.back.focus();
  1242. }
  1243. this.openPanel = {
  1244. num: 1,
  1245. item: menuItem,
  1246. };
  1247. const details = { panel: 1, item: menuItem };
  1248. this.trigger('OnOpenPanel', details);
  1249. if (this.isDesktop) {
  1250. const list = queryOne('.ecl-mega-menu__sublist', menuItem);
  1251. if (list) {
  1252. // Expand the first item in the sublist if it contains children.
  1253. const expandedChild = Array.from(
  1254. list.children,
  1255. )[0].firstElementChild.hasAttribute('aria-expanded')
  1256. ? Array.from(list.children)[0]
  1257. : false;
  1258. if (expandedChild) {
  1259. this.handleSecondPanel(expandedChild, 'expand');
  1260. }
  1261. }
  1262. }
  1263. break;
  1264. }
  1265. case 'collapse':
  1266. this.closeOpenDropdown();
  1267. break;
  1268. default:
  1269. }
  1270. }
  1271. /**
  1272. * Show/hide the second panel
  1273. *
  1274. * @param {Node} menuItem
  1275. * @param {string} op (expand or collapse)
  1276. *
  1277. * @fires MegaMenu#onOpenPanel
  1278. */
  1279. handleSecondPanel(menuItem, op) {
  1280. const infoPanel = queryOne(
  1281. '.ecl-mega-menu__info',
  1282. menuItem.closest('.ecl-container'),
  1283. );
  1284. let siblings;
  1285. switch (op) {
  1286. case 'expand': {
  1287. this.element.classList.remove(
  1288. 'ecl-mega-menu--one-panel',
  1289. 'ecl-mega-menu--start-panel',
  1290. );
  1291. this.element.classList.add('ecl-mega-menu--two-panels');
  1292. this.subItems.forEach((item) => {
  1293. const itemLink = queryOne(this.subLinkSelector, item);
  1294. if (item === menuItem) {
  1295. if (itemLink.hasAttribute('aria-expanded')) {
  1296. itemLink.setAttribute('aria-expanded', 'true');
  1297. if (!this.isDesktop) {
  1298. // We use this class mainly to recover the default behavior of the link.
  1299. itemLink.classList.add('ecl-mega-menu__parent-link');
  1300. if (this.back) {
  1301. this.back.focus();
  1302. }
  1303. }
  1304. item.classList.add('ecl-mega-menu__subitem--expanded');
  1305. }
  1306. item.classList.add('ecl-mega-menu__subitem--current');
  1307. this.backItemLevel2 = item;
  1308. } else {
  1309. if (itemLink.hasAttribute('aria-expanded')) {
  1310. itemLink.setAttribute('aria-expanded', 'false');
  1311. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1312. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1313. }
  1314. item.classList.remove('ecl-mega-menu__subitem--current');
  1315. }
  1316. });
  1317. this.openPanel = { num: 2, item: menuItem };
  1318. siblings = menuItem.parentNode.childNodes;
  1319. if (this.isDesktop) {
  1320. // Reset style for the siblings, in case they were hidden
  1321. siblings.forEach((sibling) => {
  1322. if (sibling !== menuItem) {
  1323. sibling.style.display = '';
  1324. }
  1325. });
  1326. } else {
  1327. // Hide other items in the sublist
  1328. siblings.forEach((sibling) => {
  1329. if (sibling !== menuItem) {
  1330. sibling.style.display = 'none';
  1331. }
  1332. });
  1333. }
  1334. this.positionMenuOverlay();
  1335. const details = { panel: 2, item: menuItem };
  1336. this.trigger('OnOpenPanel', details);
  1337. break;
  1338. }
  1339. case 'collapse':
  1340. this.element.classList.remove('ecl-mega-menu--two-panels');
  1341. this.openPanel = { num: 1 };
  1342. // eslint-disable-next-line no-case-declarations
  1343. const itemLink = queryOne(this.subLinkSelector, menuItem);
  1344. itemLink.setAttribute('aria-expanded', 'false');
  1345. menuItem.classList.remove(
  1346. 'ecl-mega-menu__subitem--expanded',
  1347. 'ecl-mega-menu__subitem--current',
  1348. );
  1349. if (infoPanel) {
  1350. infoPanel.style.top = '';
  1351. }
  1352. break;
  1353. default:
  1354. }
  1355. }
  1356. /**
  1357. * Click on a menu item
  1358. *
  1359. * @param {Event} e
  1360. *
  1361. * @fires MegaMenu#onItemClick
  1362. */
  1363. handleClickOnItem(e) {
  1364. let isInTheContainer = false;
  1365. const menuItem = e.target.closest('li');
  1366. const container = queryOne(
  1367. '.ecl-mega-menu__mega-container-scrollable',
  1368. menuItem,
  1369. );
  1370. if (container) {
  1371. isInTheContainer = container.contains(e.target);
  1372. }
  1373. // We need to ensure that the click doesn't come from a parent link
  1374. // or from an open container, in that case we do not act.
  1375. if (
  1376. !e.target.classList.contains(
  1377. 'ecl-mega-menu__mega-container-scrollable',
  1378. ) &&
  1379. !isInTheContainer
  1380. ) {
  1381. this.trigger('onItemClick', { item: menuItem, event: e });
  1382. const hasChildren =
  1383. menuItem.firstElementChild.getAttribute('aria-expanded');
  1384. if (hasChildren && menuItem.classList.contains('ecl-mega-menu__item')) {
  1385. e.preventDefault();
  1386. e.stopPropagation();
  1387. if (!this.isDesktop) {
  1388. this.handleFirstPanel(menuItem, 'expand');
  1389. } else {
  1390. const isOpen = hasChildren === 'true';
  1391. if (isOpen) {
  1392. this.handleFirstPanel(menuItem, 'collapse');
  1393. } else {
  1394. this.closeOpenDropdown();
  1395. this.handleFirstPanel(menuItem, 'expand');
  1396. }
  1397. }
  1398. }
  1399. }
  1400. }
  1401. /**
  1402. * Click on a subitem
  1403. *
  1404. * @param {Event} e
  1405. */
  1406. handleClickOnSubitem(e) {
  1407. const menuItem = e.target.closest(this.subItemSelector);
  1408. if (menuItem && menuItem.firstElementChild.hasAttribute('aria-expanded')) {
  1409. const parentLink = queryOne('.ecl-mega-menu__parent-link', menuItem);
  1410. if (parentLink) {
  1411. return;
  1412. }
  1413. e.preventDefault();
  1414. e.stopPropagation();
  1415. const isExpanded =
  1416. menuItem.firstElementChild.getAttribute('aria-expanded') === 'true';
  1417. if (isExpanded) {
  1418. this.handleSecondPanel(menuItem, 'collapse');
  1419. } else {
  1420. this.handleSecondPanel(menuItem, 'expand');
  1421. }
  1422. }
  1423. }
  1424. /**
  1425. * Deselect any opened menu item
  1426. *
  1427. * @param {boolean} esc, whether the call was originated by a press on Esc
  1428. *
  1429. * @fires MegaMenu#onFocusTrapToggle
  1430. */
  1431. closeOpenDropdown(esc = false) {
  1432. if (this.header) {
  1433. this.header.classList.remove(
  1434. 'ecl-site-header--open-menu',
  1435. 'ecl-site-header--open-menu-start',
  1436. );
  1437. if (this.headerBanner) {
  1438. this.headerBanner.style.display = 'flex';
  1439. }
  1440. if (this.headerNotification) {
  1441. this.headerNotification.style.display = 'flex';
  1442. }
  1443. }
  1444. this.enableScroll();
  1445. this.element.setAttribute('aria-expanded', 'false');
  1446. this.element.removeAttribute('data-expanded');
  1447. this.element.classList.remove(
  1448. 'ecl-mega-menu--start-panel',
  1449. 'ecl-mega-menu--two-panels',
  1450. 'ecl-mega-menu--one-panel',
  1451. );
  1452. this.open.setAttribute('aria-expanded', 'false');
  1453. // Remove css class and attribute from inner menu
  1454. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  1455. // Reset heights
  1456. const megaMenus = queryAll(
  1457. '.ecl-mega-menu__item > .ecl-mega-menu__wrapper > .ecl-container > [data-ecl-mega-menu-mega]',
  1458. this.element,
  1459. );
  1460. megaMenus.forEach((mega) => {
  1461. mega.style.height = '';
  1462. mega.style.top = '';
  1463. });
  1464. let currentItem = false;
  1465. // Remove css class and attribute from menu items
  1466. this.items.forEach((item) => {
  1467. item.classList.remove('ecl-mega-menu__item--current');
  1468. const itemLink = queryOne(this.linkSelector, item);
  1469. if (itemLink.getAttribute('aria-expanded') === 'true') {
  1470. item.classList.remove('ecl-mega-menu__item--expanded');
  1471. itemLink.setAttribute('aria-expanded', 'false');
  1472. currentItem = itemLink;
  1473. }
  1474. });
  1475. // Remove css class and attribute from menu subitems
  1476. this.subItems.forEach((item) => {
  1477. item.classList.remove('ecl-mega-menu__subitem--current');
  1478. item.style.display = '';
  1479. const itemLink = queryOne(this.subLinkSelector, item);
  1480. if (itemLink.hasAttribute('aria-expanded')) {
  1481. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1482. item.style.display = '';
  1483. itemLink.setAttribute('aria-expanded', 'false');
  1484. itemLink.classList.remove('ecl-mega-menu__parent-link');
  1485. }
  1486. });
  1487. // Remove styles set for the sublists
  1488. const sublists = queryAll('.ecl-mega-menu__sublist');
  1489. if (sublists) {
  1490. sublists.forEach((sublist) => {
  1491. sublist.classList.remove(
  1492. 'ecl-mega-menu__sublist--no-border',
  1493. '.ecl-mega-menu__sublist--scrollable',
  1494. );
  1495. });
  1496. }
  1497. // Update label
  1498. const openLabel = this.element.getAttribute(this.labelOpenAttribute);
  1499. if (this.toggleLabel && openLabel) {
  1500. this.toggleLabel.innerHTML = openLabel;
  1501. }
  1502. this.openPanel = {
  1503. num: 0,
  1504. item: false,
  1505. };
  1506. // If the focus trap is active, deactivate it
  1507. this.focusTrap.deactivate();
  1508. // Focus on the open button in mobile or on the formerly expanded item in desktop.
  1509. if (!this.isDesktop && this.open && esc) {
  1510. this.open.focus();
  1511. } else if (this.isDesktop && currentItem && esc) {
  1512. currentItem.focus();
  1513. }
  1514. this.trigger('onFocusTrapToggle', { active: false });
  1515. this.isOpen = false;
  1516. }
  1517. /**
  1518. * Focus out of a menu link
  1519. *
  1520. * @param {Event} e
  1521. *
  1522. * @fires MegaMenu#onFocusTrapToggle
  1523. */
  1524. handleFocusOut(e) {
  1525. const element = e.target;
  1526. const menuExpanded = this.element.getAttribute('aria-expanded');
  1527. // Specific focus action for mobile menu
  1528. // Loop through the items and go back to close button
  1529. if (menuExpanded === 'true' && !this.isDesktop) {
  1530. const nextItem = element.parentElement.nextSibling;
  1531. if (!nextItem) {
  1532. const nextFocusTarget = e.relatedTarget;
  1533. if (!this.element.contains(nextFocusTarget)) {
  1534. // This is the last item, go back to close button
  1535. this.focusTrap.activate();
  1536. this.trigger('onFocusTrapToggle', {
  1537. active: true,
  1538. lastFocusedEl: element.parentElement,
  1539. });
  1540. }
  1541. }
  1542. }
  1543. }
  1544. /**
  1545. * Handles global click events, triggered outside of the menu.
  1546. *
  1547. * @param {Event} e
  1548. */
  1549. handleClickGlobal(e) {
  1550. if (
  1551. !e.target.classList.contains(
  1552. 'ecl-mega-menu__mega-container-scrollable',
  1553. ) &&
  1554. (e.target.classList.contains('ecl-mega-menu__overlay') ||
  1555. !this.element.contains(e.target)) &&
  1556. this.isOpen
  1557. ) {
  1558. this.closeOpenDropdown();
  1559. }
  1560. }
  1561. }
  1562. export default MegaMenu;