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