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