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.listSelector Selector for the menu items list
  13. * @param {String} options.itemSelector Selector for the menu item
  14. * @param {String} options.linkSelector Selector for the menu 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. listSelector = '[data-ecl-mega-menu-list]',
  72. itemSelector = '[data-ecl-mega-menu-item]',
  73. linkSelector = '[data-ecl-mega-menu-link]',
  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.listSelector = listSelector;
  99. this.itemSelector = itemSelector;
  100. this.linkSelector = linkSelector;
  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.inner = null;
  117. this.itemsList = null;
  118. this.items = null;
  119. this.links = null;
  120. this.isOpen = false;
  121. this.resizeTimer = null;
  122. this.isKeyEvent = false;
  123. this.isDesktop = false;
  124. this.isLarge = false;
  125. this.lastVisibleItem = null;
  126. this.currentItem = null;
  127. this.totalItemsWidth = 0;
  128. this.breakpointL = 996;
  129. this.openPanel = { num: 0, item: {} };
  130. // Bind `this` for use in callbacks
  131. this.handleClickOnOpen = this.handleClickOnOpen.bind(this);
  132. this.handleClickOnClose = this.handleClickOnClose.bind(this);
  133. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  134. this.handleClickOnBack = this.handleClickOnBack.bind(this);
  135. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  136. this.handleClickOnItem = this.handleClickOnItem.bind(this);
  137. this.handleClickOnSubitem = this.handleClickOnSubitem.bind(this);
  138. this.handleFocusOut = this.handleFocusOut.bind(this);
  139. this.handleKeyboard = this.handleKeyboard.bind(this);
  140. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  141. this.handleResize = this.handleResize.bind(this);
  142. this.useDesktopDisplay = this.useDesktopDisplay.bind(this);
  143. this.checkMegaMenu = this.checkMegaMenu.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.toggleLabel = queryOne('.ecl-link__label', this.open);
  164. this.back = queryOne(this.backSelector, this.element);
  165. this.inner = queryOne(this.innerSelector, this.element);
  166. this.itemsList = queryOne(this.listSelector, this.element);
  167. this.btnPrevious = queryOne(this.buttonPreviousSelector, this.element);
  168. this.btnNext = queryOne(this.buttonNextSelector, this.element);
  169. this.items = queryAll(this.itemSelector, this.element);
  170. this.subItems = queryAll(this.subItemSelector, this.element);
  171. this.links = queryAll(this.linkSelector, this.element);
  172. // Check if we should use desktop display (it does not rely only on breakpoints)
  173. this.isDesktop = this.useDesktopDisplay();
  174. // Bind click events on buttons
  175. if (this.attachClickListener) {
  176. // Open
  177. if (this.open) {
  178. this.open.addEventListener('click', this.handleClickOnToggle);
  179. }
  180. // Back
  181. if (this.back) {
  182. this.back.addEventListener('click', this.handleClickOnBack);
  183. this.back.addEventListener('keyup', this.handleKeyboard);
  184. }
  185. // Global click
  186. if (this.attachClickListener) {
  187. document.addEventListener('click', this.handleClickGlobal);
  188. }
  189. }
  190. // Bind event on menu links
  191. if (this.links) {
  192. this.links.forEach((link) => {
  193. if (this.attachFocusListener) {
  194. link.addEventListener('focusout', this.handleFocusOut);
  195. }
  196. if (this.attachKeyListener) {
  197. link.addEventListener('keyup', this.handleKeyboard);
  198. }
  199. });
  200. }
  201. // Bind event on sub menu links
  202. if (this.subItems) {
  203. this.subItems.forEach((subItem) => {
  204. const subLink = queryOne('.ecl-mega-menu__sublink', subItem);
  205. if (this.attachKeyListener && subLink) {
  206. subLink.addEventListener('click', this.handleClickOnSubitem);
  207. subLink.addEventListener('keyup', this.handleKeyboard);
  208. }
  209. if (this.attachFocusListener && subLink) {
  210. subLink.addEventListener('focusout', this.handleFocusOut);
  211. }
  212. });
  213. }
  214. const seeAllLinks = queryAll('.ecl-mega-menu__see-all a', this.element);
  215. if (seeAllLinks.length > 0) {
  216. seeAllLinks.forEach((seeAll) => {
  217. seeAll.addEventListener('keyup', this.handleKeyboard);
  218. seeAll.addEventListener('blur', this.handleFocusOut);
  219. });
  220. }
  221. // Bind global keyboard events
  222. if (this.attachKeyListener) {
  223. document.addEventListener('keyup', this.handleKeyboardGlobal);
  224. }
  225. // Bind resize events
  226. if (this.attachResizeListener) {
  227. window.addEventListener('resize', this.handleResize);
  228. }
  229. // Browse first level items
  230. if (this.items) {
  231. this.items.forEach((item) => {
  232. // Check menu item display (right to left, full width, ...)
  233. this.totalItemsWidth += item.offsetWidth;
  234. if (
  235. item.hasAttribute('data-ecl-has-children') ||
  236. item.hasAttribute('data-ecl-has-container')
  237. ) {
  238. // Bind click event on menu items
  239. if (this.attachClickListener) {
  240. item.addEventListener('click', this.handleClickOnItem);
  241. }
  242. }
  243. });
  244. }
  245. // Create a focus trap around the menu
  246. this.focusTrap = createFocusTrap(this.element, {
  247. onActivate: () =>
  248. this.element.classList.add('ecl-mega-menu-trap-is-active'),
  249. onDeactivate: () =>
  250. this.element.classList.remove('ecl-mega-menu-trap-is-active'),
  251. });
  252. this.handleResize();
  253. // Set ecl initialized attribute
  254. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  255. ECL.components.set(this.element, this);
  256. }
  257. /**
  258. * Register a callback function for a specific event.
  259. *
  260. * @param {string} eventName - The name of the event to listen for.
  261. * @param {Function} callback - The callback function to be invoked when the event occurs.
  262. * @returns {void}
  263. * @memberof MegaMenu
  264. * @instance
  265. *
  266. * @example
  267. * // Registering a callback for the 'onOpen' event
  268. * megaMenu.on('onOpen', (event) => {
  269. * console.log('Open event occurred!', event);
  270. * });
  271. */
  272. on(eventName, callback) {
  273. this.eventManager.on(eventName, callback);
  274. }
  275. /**
  276. * Trigger a component event.
  277. *
  278. * @param {string} eventName - The name of the event to trigger.
  279. * @param {any} eventData - Data associated with the event.
  280. * @memberof MegaMenu
  281. */
  282. trigger(eventName, eventData) {
  283. this.eventManager.trigger(eventName, eventData);
  284. }
  285. /**
  286. * Destroy component.
  287. */
  288. destroy() {
  289. if (this.attachClickListener) {
  290. if (this.open) {
  291. this.open.removeEventListener('click', this.handleClickOnToggle);
  292. }
  293. if (this.back) {
  294. this.back.removeEventListener('click', this.handleClickOnBack);
  295. }
  296. if (this.attachClickListener) {
  297. document.removeEventListener('click', this.handleClickGlobal);
  298. }
  299. }
  300. if (this.items && this.isDesktop) {
  301. this.items.forEach((item) => {
  302. if (
  303. item.hasAttribute('data-ecl-has-children') ||
  304. item.hasAttribute('data-ecl-has-container')
  305. ) {
  306. if (this.attachClickListener) {
  307. item.removeEventListener('click', this.handleClickOnItem);
  308. }
  309. }
  310. });
  311. }
  312. if (this.links) {
  313. this.links.forEach((link) => {
  314. if (this.attachFocusListener) {
  315. link.removeEventListener('focusout', this.handleFocusOut);
  316. }
  317. if (this.attachKeyListener) {
  318. link.removeEventListener('keyup', this.handleKeyboard);
  319. }
  320. });
  321. }
  322. if (this.subItems) {
  323. this.subItems.forEach((subItem) => {
  324. const subLink = queryOne('.ecl-mega-menu__sublink', subItem);
  325. if (this.attachKeyListener && subLink) {
  326. subLink.removeEventListener('keyup', this.handleKeyboard);
  327. }
  328. if (this.attachClickListener && subLink) {
  329. subLink.removeEventListener('click', this.handleClickOnSubitem);
  330. }
  331. if (this.attachFocusListener && subLink) {
  332. subLink.removeEventListener('focusout', this.handleFocusOut);
  333. }
  334. });
  335. }
  336. if (this.attachKeyListener) {
  337. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  338. }
  339. if (this.attachResizeListener) {
  340. window.removeEventListener('resize', this.handleResize);
  341. }
  342. this.closeOpenDropdown();
  343. this.enableScroll();
  344. if (this.element) {
  345. this.element.removeAttribute('data-ecl-auto-initialized');
  346. ECL.components.delete(this.element);
  347. }
  348. }
  349. /**
  350. * Disable page scrolling
  351. */
  352. disableScroll() {
  353. document.body.classList.add('ecl-mega-menu-prevent-scroll');
  354. }
  355. /**
  356. * Enable page scrolling
  357. */
  358. enableScroll() {
  359. document.body.classList.remove('ecl-mega-menu-prevent-scroll');
  360. }
  361. /**
  362. * Check if desktop display has to be used
  363. * - not using a phone or tablet (whatever the screen size is)
  364. * - not having hamburger menu on screen
  365. */
  366. useDesktopDisplay() {
  367. // Detect mobile devices
  368. if (isMobile.isMobileOnly) {
  369. return false;
  370. }
  371. // Force mobile display on tablet
  372. if (isMobile.isTablet) {
  373. this.element.classList.add('ecl-mega-menu--forced-mobile');
  374. return false;
  375. }
  376. // After all that, check if the hamburger button is displayed
  377. if (window.innerWidth < this.breakpointL) {
  378. return false;
  379. }
  380. // Everything is fine to use desktop display
  381. this.element.classList.remove('ecl-mega-menu--forced-mobile');
  382. return true;
  383. }
  384. /**
  385. * Reset the styles set by the script
  386. *
  387. * @param {string} desktop or mobile
  388. */
  389. resetStyles(viewport) {
  390. const subLists = queryAll('.ecl-mega-menu__sublist', this.element);
  391. // Remove display:none from the sublists
  392. if (subLists && viewport === 'mobile') {
  393. subLists.forEach((list) => {
  394. list.style.height = '';
  395. });
  396. // Two panels are opened
  397. if (this.openPanel.num === 2) {
  398. const menuItem = this.openPanel.item;
  399. // Hide parent link in the first panel
  400. menuItem.parentNode.parentNode.firstElementChild.style.display = 'none';
  401. // Remove duplicated border
  402. menuItem.parentNode.classList.add('ecl-mega-menu__sublist--no-border');
  403. // Hide siblings
  404. const siblings = menuItem.parentNode.childNodes;
  405. siblings.forEach((sibling) => {
  406. if (sibling !== menuItem) {
  407. sibling.style.display = 'none';
  408. }
  409. });
  410. }
  411. } else if (subLists && viewport === 'desktop') {
  412. const parentLinks = queryAll('.ecl-mega-menu__parent-link');
  413. if (parentLinks) {
  414. // Reset the display for the parent links, they could be hidden
  415. parentLinks.forEach((parent) => {
  416. parent.style.display = '';
  417. });
  418. }
  419. // Reset styles for the sublist and subitems
  420. subLists.forEach((list) => {
  421. if (!this.isLarge) {
  422. list.parentNode.classList.remove('ecl-mega-menu__item--col2');
  423. }
  424. list.classList.remove('ecl-mega-menu__sublist--no-border');
  425. list.childNodes.forEach((item) => {
  426. item.style.display = '';
  427. });
  428. });
  429. // Check if we have an open item, if we don't hide the overlay and enable scroll
  430. const currentItems = [];
  431. const currentItem = queryOne(
  432. '.ecl-mega-menu__subitem--expanded',
  433. this.element,
  434. );
  435. if (currentItem) {
  436. currentItems.push(currentItem);
  437. }
  438. const currentSubItem = queryOne(
  439. '.ecl-mega-menu__item--expanded',
  440. this.element,
  441. );
  442. if (currentSubItem) {
  443. currentItems.push(currentSubItem);
  444. }
  445. if (currentItems.length > 0) {
  446. currentItems.forEach((current) => {
  447. this.checkDropdownHeight(current);
  448. });
  449. } else {
  450. this.element.setAttribute('aria-expanded', 'false');
  451. this.element.removeAttribute('data-expanded');
  452. this.enableScroll();
  453. }
  454. }
  455. }
  456. /**
  457. * Trigger events on resize
  458. * Uses a debounce, for performance
  459. */
  460. handleResize() {
  461. clearTimeout(this.resizeTimer);
  462. this.resizeTimer = setTimeout(() => {
  463. const screenWidth = window.innerWidth;
  464. if (this.prevScreenWidth !== undefined) {
  465. // Check if the transition involves crossing the L breakpoint
  466. const isTransition =
  467. (this.prevScreenWidth <= this.breakpointL &&
  468. screenWidth > this.breakpointL) ||
  469. (this.prevScreenWidth > this.breakpointL &&
  470. screenWidth <= this.breakpointL);
  471. // If we are moving in or out the L breakpoint, reset the styles
  472. if (isTransition) {
  473. this.resetStyles(
  474. screenWidth > this.breakpointL ? 'desktop' : 'mobile',
  475. );
  476. }
  477. }
  478. this.isDesktop = this.useDesktopDisplay();
  479. this.isLarge = window.innerWidth > 1140;
  480. // Update previous screen width
  481. this.prevScreenWidth = screenWidth;
  482. this.element.classList.remove('ecl-mega-menu--forced-mobile');
  483. // RTL
  484. this.direction = getComputedStyle(this.element).direction;
  485. if (this.direction === 'rtl') {
  486. this.element.classList.add('ecl-mega-menu--rtl');
  487. } else {
  488. this.element.classList.remove('ecl-mega-menu--rtl');
  489. }
  490. // Check droopdown height if needed
  491. const expanded = queryOne('.ecl-mega-menu__item--expanded', this.element);
  492. if (expanded && this.isDesktop) {
  493. this.checkDropdownHeight(expanded);
  494. }
  495. if (this.openPanel.num === 2 && this.openPanel.item) {
  496. this.checkMegaMenu(this.openPanel.item);
  497. }
  498. // Check the menu position
  499. this.positionMenuOverlay();
  500. }, 200);
  501. }
  502. /**
  503. * Calculate dropdown height dynamically
  504. *
  505. * @param {Node} menuItem
  506. */
  507. checkDropdownHeight(menuItem) {
  508. setTimeout(() => {
  509. const viewportHeight = window.innerHeight;
  510. let dropdown = queryOne('.ecl-mega-menu__sublist', menuItem);
  511. if (!dropdown) {
  512. dropdown = queryOne('.ecl-mega-menu__mega-container', menuItem);
  513. }
  514. if (dropdown) {
  515. const dropdownTop = dropdown.getBoundingClientRect().top;
  516. let dropdownHeight = viewportHeight - dropdownTop;
  517. const lastItem = queryOne('.ecl-mega-menu__see-all', dropdown);
  518. // Arbitrary, but doing this prevents a misalignment between the two panels
  519. if (lastItem) {
  520. dropdownHeight -= 20;
  521. }
  522. dropdown.style.height = `${dropdownHeight}px`;
  523. }
  524. }, 100);
  525. }
  526. /**
  527. * Dinamically set the position of the menu overlay
  528. */
  529. positionMenuOverlay() {
  530. const menuOverlay = queryOne('.ecl-mega-menu__overlay', this.element);
  531. const megaMenus = queryAll(
  532. '.ecl-mega-menu__item > .ecl-mega-menu__mega',
  533. this.element,
  534. );
  535. if (!this.isDesktop) {
  536. // In mobile, we get the bottom position of the site header header
  537. setTimeout(() => {
  538. const header = queryOne('.ecl-site-header__header', document);
  539. if (header) {
  540. const position = header.getBoundingClientRect();
  541. const bottomPosition = Math.round(position.bottom);
  542. if (menuOverlay) {
  543. menuOverlay.style.top = `${bottomPosition}px`;
  544. }
  545. if (this.inner) {
  546. this.inner.style.top = `${bottomPosition}px`;
  547. }
  548. if (megaMenus) {
  549. megaMenus.forEach((mega) => {
  550. mega.style.top = '';
  551. });
  552. }
  553. }
  554. }, 0);
  555. } else {
  556. setTimeout(() => {
  557. // In desktop we get the bottom position of the whole site header
  558. const siteHeader = queryOne('.ecl-site-header', document);
  559. if (siteHeader) {
  560. const headerRect = siteHeader.getBoundingClientRect();
  561. const headerBottom = headerRect.bottom;
  562. const item = queryOne(this.itemSelector, this.element);
  563. const rect = item.getBoundingClientRect();
  564. const rectHeight = rect.height + 4; // 4 pixels border
  565. if (megaMenus) {
  566. megaMenus.forEach((mega) => {
  567. mega.style.top = `${rectHeight}px`;
  568. });
  569. }
  570. if (menuOverlay) {
  571. menuOverlay.style.top = `${headerBottom}px`;
  572. }
  573. } else {
  574. const bottomPosition = this.element.getBoundingClientRect().bottom;
  575. if (menuOverlay) {
  576. menuOverlay.style.top = `${bottomPosition}px`;
  577. }
  578. if (megaMenus) {
  579. megaMenus.forEach((mega) => {
  580. mega.style.top = `${bottomPosition}px`;
  581. });
  582. }
  583. }
  584. }, 0);
  585. }
  586. }
  587. /**
  588. * Clone the selected item to show it on top of the panel.
  589. *
  590. * @param {Node} menuItem
  591. */
  592. cloneItemInTheDrowdown(menuItem) {
  593. const firstItemLink = queryOne('.ecl-link', menuItem).cloneNode(true);
  594. const svg = queryOne('.ecl-icon use', firstItemLink);
  595. if (svg) {
  596. const hrefValue = svg.getAttribute('xlink:href');
  597. if (hrefValue) {
  598. const newHrefValue = hrefValue.replace('corner-arrow', 'arrow-left');
  599. svg.parentElement.classList.add(
  600. 'ecl-icon--flip-horizontal',
  601. 'ecl-icon--xs',
  602. );
  603. svg.parentElement.classList.remove(
  604. 'ecl-icon--2xs',
  605. 'ecl-icon--rotate-180',
  606. );
  607. svg.setAttributeNS(
  608. 'http://www.w3.org/1999/xlink',
  609. 'xlink:href',
  610. newHrefValue,
  611. );
  612. }
  613. if (firstItemLink.id) {
  614. firstItemLink.id = `${firstItemLink.id}-parent`;
  615. }
  616. const ariaLabel = menuItem.getAttribute('data-ecl-parent-aria-label');
  617. if (ariaLabel) {
  618. firstItemLink.setAttribute('aria-label', ariaLabel);
  619. }
  620. firstItemLink.classList.add('ecl-mega-menu__parent-link');
  621. firstItemLink.addEventListener('keyup', this.handleKeyboard);
  622. let innerList = queryOne('.ecl-mega-menu__mega', menuItem);
  623. if (!innerList) {
  624. innerList = queryOne('.ecl-mega-menu__mega-container', menuItem);
  625. }
  626. if (innerList && !queryOne('.ecl-mega-menu__parent-link', menuItem)) {
  627. innerList.prepend(firstItemLink);
  628. }
  629. }
  630. }
  631. /**
  632. * Handle second panel columns
  633. *
  634. * @param {Node} menuItem
  635. */
  636. checkMegaMenu(menuItem) {
  637. const menuMega = queryOne(this.megaSelector, menuItem);
  638. if (menuMega && this.inner && this.isLarge) {
  639. const subItems = queryAll(`${this.subItemSelector} a`, menuMega);
  640. let itemsHeight = 0;
  641. subItems.forEach((item) => {
  642. itemsHeight += item.getBoundingClientRect().height;
  643. });
  644. const lastItem = queryOne('.ecl-mega-menu__see-all', menuMega);
  645. if (lastItem) {
  646. // Arbitrary, but does the job.
  647. itemsHeight += 150;
  648. }
  649. const containerBounding = this.inner.getBoundingClientRect();
  650. const containerBottom = containerBounding.bottom;
  651. const availableHeight = window.innerHeight - containerBottom;
  652. if (itemsHeight > availableHeight) {
  653. menuMega.classList.add('ecl-mega-menu__item--col2');
  654. }
  655. } else if (menuMega) {
  656. menuMega.classList.remove('ecl-mega-menu__item--col2');
  657. }
  658. }
  659. /**
  660. * Handles keyboard events specific to the menu.
  661. *
  662. * @param {Event} e
  663. */
  664. handleKeyboard(e) {
  665. const element = e.target;
  666. const cList = element.classList;
  667. const menuExpanded = this.element.getAttribute('aria-expanded');
  668. // Detect press on Escape
  669. if (e.key === 'Escape' || e.key === 'Esc') {
  670. if (document.activeElement === element) {
  671. element.blur();
  672. }
  673. if (menuExpanded === 'false') {
  674. this.closeOpenDropdown();
  675. }
  676. return;
  677. }
  678. // Handle keyboard on parent links
  679. if (cList.contains('ecl-mega-menu__parent-link')) {
  680. if (e.key === 'ArrowUp') {
  681. if (this.isDesktop) {
  682. // Focus the first level menu item
  683. element
  684. .closest('.ecl-mega-menu__item--expanded')
  685. .firstElementChild.focus();
  686. } else {
  687. // In mobile focus on the back button
  688. this.back.focus();
  689. }
  690. }
  691. if (e.key === 'ArrowDown') {
  692. // Focus on the first sub-link
  693. element.nextSibling.firstElementChild.firstElementChild.focus();
  694. }
  695. }
  696. // Handle keyboard on the see all links
  697. if (element.parentElement.classList.contains('ecl-mega-menu__see-all')) {
  698. if (e.key === 'ArrowUp') {
  699. // Focus on the last element of the sub-list
  700. element.parentElement.previousSibling.firstChild.focus();
  701. }
  702. }
  703. // Handle keyboard on the back button
  704. if (cList.contains('ecl-mega-menu__back')) {
  705. if (e.key === 'ArrowDown') {
  706. e.preventDefault();
  707. const expanded = queryOne(
  708. '[aria-expanded="true"]',
  709. element.parentElement.nextSibling,
  710. );
  711. // We have an opened list
  712. if (expanded) {
  713. const innerExpanded = queryOne('[aria-expanded="true"]', expanded);
  714. // We have an opened sub-list
  715. if (innerExpanded) {
  716. queryOne('.ecl-mega-menu__parent-link', innerExpanded).focus();
  717. } else {
  718. queryOne('.ecl-mega-menu__parent-link', expanded).focus();
  719. }
  720. }
  721. }
  722. if (e.key === 'ArrowUp') {
  723. // Focus on the open button
  724. this.open.focus();
  725. }
  726. }
  727. // Key actions to navigate between first level menu items
  728. if (cList.contains('ecl-mega-menu__link')) {
  729. if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
  730. e.preventDefault();
  731. let prevItem = element.previousSibling;
  732. if (prevItem && prevItem.classList.contains('ecl-mega-menu__link')) {
  733. prevItem.focus();
  734. return;
  735. }
  736. prevItem = element.parentElement.previousSibling;
  737. if (prevItem) {
  738. const prevLink = queryOne('.ecl-mega-menu__link', prevItem);
  739. if (prevLink) {
  740. prevLink.focus();
  741. return;
  742. }
  743. }
  744. }
  745. if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
  746. e.preventDefault();
  747. if (
  748. element.parentElement.getAttribute('aria-expanded') === 'true' &&
  749. e.key === 'ArrowDown'
  750. ) {
  751. const parentLink = queryOne(
  752. '.ecl-mega-menu__parent-link',
  753. element.parentElement,
  754. );
  755. if (parentLink) {
  756. parentLink.focus();
  757. return;
  758. }
  759. }
  760. const nextItem = element.parentElement.nextSibling;
  761. if (nextItem) {
  762. const nextLink = queryOne('.ecl-mega-menu__link', nextItem);
  763. if (nextLink) {
  764. nextLink.focus();
  765. return;
  766. }
  767. }
  768. }
  769. }
  770. // Key actions to navigate between the sub-links
  771. if (cList.contains('ecl-mega-menu__sublink')) {
  772. if (e.key === 'ArrowDown') {
  773. e.preventDefault();
  774. const nextItem = element.parentElement.nextSibling;
  775. let nextLink = '';
  776. if (nextItem) {
  777. nextLink = queryOne('.ecl-mega-menu__sublink', nextItem);
  778. if (
  779. !nextLink &&
  780. nextItem.classList.contains('ecl-mega-menu__see-all')
  781. ) {
  782. nextLink = nextItem.firstElementChild;
  783. }
  784. if (nextLink) {
  785. nextLink.focus();
  786. return;
  787. }
  788. }
  789. }
  790. if (e.key === 'ArrowUp') {
  791. e.preventDefault();
  792. const prevItem = element.parentElement.previousSibling;
  793. if (prevItem) {
  794. const prevLink = queryOne('.ecl-mega-menu__sublink', prevItem);
  795. if (prevLink) {
  796. prevLink.focus();
  797. }
  798. } else {
  799. element.parentElement.parentElement.previousSibling.focus();
  800. }
  801. }
  802. }
  803. if (e.key === 'ArrowRight') {
  804. const expanded =
  805. element.parentElement.getAttribute('aria-expanded') === 'true';
  806. if (expanded) {
  807. e.preventDefault();
  808. // Focus on the first element in the second panel
  809. element.nextSibling.firstElementChild.focus();
  810. }
  811. }
  812. }
  813. /**
  814. * Handles global keyboard events, triggered outside of the menu.
  815. *
  816. * @param {Event} e
  817. */
  818. handleKeyboardGlobal(e) {
  819. const menuExpanded = this.element.getAttribute('aria-expanded');
  820. // Detect press on Escape
  821. if (e.key === 'Escape' || e.key === 'Esc') {
  822. if (menuExpanded === 'true') {
  823. this.closeOpenDropdown();
  824. }
  825. }
  826. }
  827. /**
  828. * Open menu list.
  829. *
  830. * @param {Event} e
  831. *
  832. * @fires MegaMenu#onOpen
  833. */
  834. handleClickOnOpen(e) {
  835. if (this.element.getAttribute('aria-expanded') === 'true') {
  836. this.handleClickOnClose(e);
  837. } else {
  838. e.preventDefault();
  839. this.disableScroll();
  840. this.element.setAttribute('aria-expanded', 'true');
  841. this.inner.setAttribute('aria-hidden', 'false');
  842. this.isOpen = true;
  843. this.openPanel.num = 1;
  844. // Update label
  845. const closeLabel = this.element.getAttribute(this.labelCloseAttribute);
  846. if (this.toggleLabel && closeLabel) {
  847. this.toggleLabel.innerHTML = closeLabel;
  848. }
  849. this.trigger('onOpen', e);
  850. }
  851. }
  852. /**
  853. * Close menu list.
  854. *
  855. * @param {Event} e
  856. *
  857. * @fires Menu#onClose
  858. */
  859. handleClickOnClose(e) {
  860. if (this.element.getAttribute('aria-expanded') === 'true') {
  861. this.focusTrap.deactivate();
  862. this.closeOpenDropdown();
  863. this.trigger('onClose', e);
  864. } else {
  865. this.handleClickOnOpen(e);
  866. }
  867. }
  868. /**
  869. * Toggle menu list.
  870. *
  871. * @param {Event} e
  872. */
  873. handleClickOnToggle(e) {
  874. e.preventDefault();
  875. if (this.isOpen) {
  876. this.handleClickOnClose(e);
  877. } else {
  878. this.handleClickOnOpen(e);
  879. }
  880. }
  881. /**
  882. * Get back to previous list (on mobile)
  883. *
  884. * @fires MegaMenu#onBack
  885. */
  886. handleClickOnBack() {
  887. const level2 = queryOne('.ecl-mega-menu__subitem--expanded', this.element);
  888. if (level2) {
  889. const parentLinks = queryAll('.ecl-mega-menu__parent-link', this.element);
  890. if (parentLinks) {
  891. parentLinks.forEach((parent) => {
  892. parent.style.display = '';
  893. });
  894. }
  895. const sublists = queryAll('.ecl-mega-menu__sublist');
  896. if (sublists) {
  897. sublists.forEach((sublist) => {
  898. sublist.classList.remove('ecl-mega-menu__sublist--no-border');
  899. });
  900. }
  901. level2.setAttribute('aria-expanded', 'false');
  902. level2.classList.remove(
  903. 'ecl-mega-menu__subitem--expanded',
  904. 'ecl-mega-menu__subitem--current',
  905. );
  906. const siblings = level2.parentElement.childNodes;
  907. if (siblings) {
  908. siblings.forEach((sibling) => {
  909. sibling.style.display = '';
  910. });
  911. }
  912. // Move focus on the parent link of the opened list
  913. const expanded = queryOne('.ecl-mega-menu__item--expanded', this.element);
  914. queryOne('.ecl-mega-menu__parent-link', expanded).focus();
  915. this.openPanel.num = 1;
  916. } else {
  917. // Remove expanded class from inner menu
  918. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  919. // Remove css class and attribute from menu items
  920. this.items.forEach((item) => {
  921. item.classList.remove(
  922. 'ecl-mega-menu__item--expanded',
  923. 'ecl-mega-menu__item--current',
  924. );
  925. item.setAttribute('aria-expanded', 'false');
  926. });
  927. // Move the focus to the first item in the menu
  928. this.items[0].firstElementChild.focus();
  929. this.openPanel.num = 0;
  930. }
  931. this.trigger('onBack', { level: level2 ? 2 : 1 });
  932. }
  933. /**
  934. * Show/hide the first panel
  935. *
  936. * @param {Node} menuItem
  937. * @param {string} op (expand or collapse)
  938. *
  939. * @fires MegaMenu#onOpenPanel
  940. */
  941. handleFirstPanel(menuItem, op) {
  942. switch (op) {
  943. case 'expand': {
  944. this.inner.classList.add('ecl-mega-menu__inner--expanded');
  945. this.positionMenuOverlay();
  946. this.cloneItemInTheDrowdown(menuItem);
  947. this.checkDropdownHeight(menuItem);
  948. this.element.setAttribute('data-expanded', true);
  949. this.element.setAttribute('aria-expanded', 'true');
  950. this.disableScroll();
  951. this.items.forEach((item) => {
  952. if (item.hasAttribute('aria-expanded')) {
  953. if (item === menuItem) {
  954. item.classList.add(
  955. 'ecl-mega-menu__item--expanded',
  956. 'ecl-mega-menu__item--current',
  957. );
  958. item.setAttribute('aria-expanded', 'true');
  959. } else {
  960. item.setAttribute('aria-expanded', 'false');
  961. item.classList.remove(
  962. 'ecl-mega-menu__item--current',
  963. 'ecl-mega-menu__item--expanded',
  964. );
  965. }
  966. }
  967. });
  968. queryOne('.ecl-mega-menu__parent-link', menuItem).focus();
  969. const details = { panel: 1, item: menuItem };
  970. this.trigger('OnOpenPanel', details);
  971. break;
  972. }
  973. case 'collapse':
  974. this.closeOpenDropdown();
  975. break;
  976. default:
  977. }
  978. }
  979. /**
  980. * Show/hide the second panel
  981. *
  982. * @param {Node} menuItem
  983. * @param {string} op (expand or collapse)
  984. *
  985. * @fires MegaMenu#onOpenPanel
  986. */
  987. handleSecondPanel(menuItem, op) {
  988. let siblings;
  989. switch (op) {
  990. case 'expand': {
  991. this.subItems.forEach((item) => {
  992. if (item === menuItem) {
  993. if (item.hasAttribute('aria-expanded')) {
  994. item.setAttribute('aria-expanded', 'true');
  995. item.classList.add('ecl-mega-menu__subitem--expanded');
  996. }
  997. item.classList.add('ecl-mega-menu__subitem--current');
  998. } else {
  999. if (item.hasAttribute('aria-expanded')) {
  1000. item.setAttribute('aria-expanded', 'false');
  1001. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1002. }
  1003. item.classList.remove('ecl-mega-menu__subitem--current');
  1004. }
  1005. });
  1006. this.openPanel = { num: 2, item: menuItem };
  1007. siblings = menuItem.parentNode.childNodes;
  1008. if (this.isDesktop) {
  1009. this.checkDropdownHeight(menuItem);
  1010. // Reset style for the siblings, in case they were hidden
  1011. siblings.forEach((sibling) => {
  1012. if (sibling !== menuItem) {
  1013. sibling.style.display = '';
  1014. }
  1015. });
  1016. } else {
  1017. // Hide parent link of the first panel
  1018. menuItem.parentNode.parentNode.firstElementChild.style.display =
  1019. 'none';
  1020. // Remove double border, we have two sublists opened
  1021. menuItem.parentNode.classList.add(
  1022. 'ecl-mega-menu__sublist--no-border',
  1023. );
  1024. // Hide other items in the sublist
  1025. siblings.forEach((sibling) => {
  1026. if (sibling !== menuItem) {
  1027. sibling.style.display = 'none';
  1028. }
  1029. });
  1030. }
  1031. queryOne('.ecl-mega-menu__parent-link', menuItem).focus();
  1032. this.checkMegaMenu(menuItem);
  1033. const details = { panel: 2, item: menuItem };
  1034. this.trigger('OnOpenPanel', details);
  1035. break;
  1036. }
  1037. case 'collapse':
  1038. this.openPanel = { num: 1 };
  1039. menuItem.setAttribute('aria-expanded', 'false');
  1040. menuItem.classList.remove(
  1041. 'ecl-mega-menu__subitem--expanded',
  1042. 'ecl-mega-menu__subitem--current',
  1043. );
  1044. break;
  1045. default:
  1046. }
  1047. }
  1048. /**
  1049. * Click on a menu item
  1050. *
  1051. * @param {Event} e
  1052. *
  1053. * @fires MegaMenu#onItemClick
  1054. */
  1055. handleClickOnItem(e) {
  1056. let isInTheContainer = false;
  1057. const menuItem = e.target.closest('li');
  1058. const container = queryOne(
  1059. '.ecl-mega-menu__mega-container-scrollable',
  1060. menuItem,
  1061. );
  1062. if (container) {
  1063. isInTheContainer = container.contains(e.target);
  1064. }
  1065. // We need to ensure that the click doesn't come from a parent link
  1066. // or from an open container, in that case we do not act.
  1067. if (
  1068. !e.target.parentNode.classList.contains('ecl-mega-menu__parent-link') &&
  1069. !e.target.classList.contains(
  1070. 'ecl-mega-menu__mega-container-scrollable',
  1071. ) &&
  1072. !isInTheContainer
  1073. ) {
  1074. this.trigger('onItemClick', { item: menuItem, event: e });
  1075. const hasChildren = menuItem.getAttribute('aria-expanded');
  1076. if (hasChildren && menuItem.classList.contains('ecl-mega-menu__item')) {
  1077. e.preventDefault();
  1078. if (!this.isDesktop) {
  1079. this.handleFirstPanel(menuItem, 'expand');
  1080. } else {
  1081. const isExpandable = hasChildren === 'true';
  1082. if (isExpandable) {
  1083. this.handleFirstPanel(menuItem, 'collapse');
  1084. } else {
  1085. this.closeOpenDropdown();
  1086. this.handleFirstPanel(menuItem, 'expand');
  1087. }
  1088. }
  1089. }
  1090. }
  1091. }
  1092. /**
  1093. * Click on a subitem
  1094. *
  1095. * @param {Event} e
  1096. */
  1097. handleClickOnSubitem(e) {
  1098. const menuItem = e.target.closest(this.subItemSelector);
  1099. if (menuItem && menuItem.hasAttribute('aria-expanded')) {
  1100. e.preventDefault();
  1101. const isExpanded = menuItem.getAttribute('aria-expanded') === 'true';
  1102. this.cloneItemInTheDrowdown(menuItem);
  1103. if (isExpanded) {
  1104. this.handleSecondPanel(menuItem, 'collapse');
  1105. } else {
  1106. this.handleSecondPanel(menuItem, 'expand');
  1107. }
  1108. }
  1109. }
  1110. /**
  1111. * Deselect any opened menu item
  1112. *
  1113. * @fires MegaMenu#onFocusTrapToggle
  1114. */
  1115. closeOpenDropdown() {
  1116. this.enableScroll();
  1117. this.element.setAttribute('aria-expanded', 'false');
  1118. this.element.removeAttribute('data-expanded');
  1119. // Remove css class and attribute from inner menu
  1120. this.inner.classList.remove('ecl-mega-menu__inner--expanded');
  1121. this.inner.setAttribute('aria-hidden', 'true');
  1122. // Remove css class and attribute from menu items
  1123. this.items.forEach((item) => {
  1124. item.classList.remove('ecl-mega-menu__item--current');
  1125. if (item.hasAttribute('aria-expanded')) {
  1126. item.setAttribute('aria-expanded', 'false');
  1127. item.classList.remove('ecl-mega-menu__item--expanded');
  1128. }
  1129. });
  1130. // Remove css class and attribute from menu subitems
  1131. this.subItems.forEach((item) => {
  1132. item.classList.remove('ecl-mega-menu__subitem--current');
  1133. if (item.hasAttribute('aria-expanded')) {
  1134. item.classList.remove('ecl-mega-menu__subitem--expanded');
  1135. item.setAttribute('aria-expanded', 'false');
  1136. item.style.display = '';
  1137. }
  1138. });
  1139. // Remove styles set for the sublists
  1140. const sublists = queryAll('.ecl-mega-menu__sublist');
  1141. if (sublists) {
  1142. sublists.forEach((sublist) => {
  1143. sublist.classList.remove('ecl-mega-menu__sublist--no-border');
  1144. });
  1145. }
  1146. // Remove styles set for the parent links
  1147. const parentLinks = queryAll('.ecl-mega-menu__parent-link', this.element);
  1148. if (parentLinks) {
  1149. parentLinks.forEach((parent) => {
  1150. parent.style.display = '';
  1151. });
  1152. }
  1153. // Update label
  1154. const openLabel = this.element.getAttribute(this.labelOpenAttribute);
  1155. if (this.toggleLabel && openLabel) {
  1156. this.toggleLabel.innerHTML = openLabel;
  1157. }
  1158. this.openPanel.num = 0;
  1159. // If the focus trap is active, deactivate it
  1160. this.focusTrap.deactivate();
  1161. this.trigger('onFocusTrapToggle', { active: false });
  1162. this.isOpen = false;
  1163. }
  1164. /**
  1165. * Focus out of a menu link
  1166. *
  1167. * @param {Event} e
  1168. *
  1169. * @fires MegaMenu#onFocusTrapToggle
  1170. */
  1171. handleFocusOut(e) {
  1172. const element = e.target;
  1173. const menuExpanded = this.element.getAttribute('aria-expanded');
  1174. // Specific focus action for mobile menu
  1175. // Loop through the items and go back to close button
  1176. if (menuExpanded === 'true') {
  1177. const nextItem = element.parentElement.nextSibling;
  1178. if (!nextItem) {
  1179. const nextFocusTarget = e.relatedTarget;
  1180. if (!this.element.contains(nextFocusTarget)) {
  1181. // This is the last item, go back to close button
  1182. this.focusTrap.activate();
  1183. this.trigger('onFocusTrapToggle', {
  1184. active: true,
  1185. lastFocusedEl: element.parentElement,
  1186. });
  1187. }
  1188. }
  1189. }
  1190. }
  1191. /**
  1192. * Handles global click events, triggered outside of the menu.
  1193. *
  1194. * @param {Event} e
  1195. */
  1196. handleClickGlobal(e) {
  1197. if (
  1198. !e.target.classList.contains(
  1199. 'ecl-mega-menu__mega-container-scrollable',
  1200. ) &&
  1201. (e.target.classList.contains('ecl-mega-menu__overlay') ||
  1202. !this.element.contains(e.target))
  1203. ) {
  1204. this.closeOpenDropdown();
  1205. }
  1206. }
  1207. }
  1208. export default MegaMenu;