menu.js

  1. import Stickyfill from 'stickyfilljs';
  2. import { queryOne, queryAll } from '@ecl/dom-utils';
  3. import EventManager from '@ecl/event-manager';
  4. import isMobile from 'mobile-device-detect';
  5. /**
  6. * @param {HTMLElement} element DOM element for component instantiation and scope
  7. * @param {Object} options
  8. * @param {String} options.openSelector Selector for the hamburger button
  9. * @param {String} options.closeSelector Selector for the close 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.buttonPreviousSelector Selector for the previous items button (for overflow)
  16. * @param {String} options.buttonNextSelector Selector for the next items button (for overflow)
  17. * @param {String} options.megaSelector Selector for the mega menu
  18. * @param {String} options.subItemSelector Selector for the menu sub items
  19. * @param {Int} options.maxLines Number of lines maximum for each menu item (for overflow). Set it to zero to disable automatic resize.
  20. * @param {String} options.maxLinesAttribute The data attribute to set the max lines in the markup, if needed
  21. * @param {String} options.labelOpenAttribute The data attribute for open label
  22. * @param {String} options.labelCloseAttribute The data attribute for close label
  23. * @param {Boolean} options.attachClickListener Whether or not to bind click events
  24. * @param {Boolean} options.attachHoverListener Whether or not to bind hover events
  25. * @param {Boolean} options.attachFocusListener Whether or not to bind focus events
  26. * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
  27. * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
  28. */
  29. export class Menu {
  30. /**
  31. * @static
  32. * Shorthand for instance creation and initialisation.
  33. *
  34. * @param {HTMLElement} root DOM element for component instantiation and scope
  35. *
  36. * @return {Menu} An instance of Menu.
  37. */
  38. static autoInit(root, { MENU: defaultOptions = {} } = {}) {
  39. const menu = new Menu(root, defaultOptions);
  40. menu.init();
  41. root.ECLMenu = menu;
  42. return menu;
  43. }
  44. /**
  45. * @event Menu#onOpen
  46. */
  47. /**
  48. * @event Menu#onClose
  49. */
  50. /**
  51. * An array of supported events for this component.
  52. *
  53. * @type {Array<string>}
  54. * @memberof Menu
  55. */
  56. supportedEvents = ['onOpen', 'onClose'];
  57. constructor(
  58. element,
  59. {
  60. openSelector = '[data-ecl-menu-open]',
  61. closeSelector = '[data-ecl-menu-close]',
  62. backSelector = '[data-ecl-menu-back]',
  63. innerSelector = '[data-ecl-menu-inner]',
  64. listSelector = '[data-ecl-menu-list]',
  65. itemSelector = '[data-ecl-menu-item]',
  66. linkSelector = '[data-ecl-menu-link]',
  67. buttonPreviousSelector = '[data-ecl-menu-items-previous]',
  68. buttonNextSelector = '[data-ecl-menu-items-next]',
  69. caretSelector = '[data-ecl-menu-caret]',
  70. megaSelector = '[data-ecl-menu-mega]',
  71. subItemSelector = '[data-ecl-menu-subitem]',
  72. maxLines = 2,
  73. maxLinesAttribute = 'data-ecl-menu-max-lines',
  74. labelOpenAttribute = 'data-ecl-menu-label-open',
  75. labelCloseAttribute = 'data-ecl-menu-label-close',
  76. attachClickListener = true,
  77. attachHoverListener = true,
  78. attachFocusListener = true,
  79. attachKeyListener = true,
  80. attachResizeListener = true,
  81. onCloseCallback = null,
  82. onOpenCallback = null,
  83. } = {},
  84. ) {
  85. // Check element
  86. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  87. throw new TypeError(
  88. 'DOM element should be given to initialize this widget.',
  89. );
  90. }
  91. this.element = element;
  92. this.eventManager = new EventManager();
  93. // Options
  94. this.openSelector = openSelector;
  95. this.closeSelector = closeSelector;
  96. this.backSelector = backSelector;
  97. this.innerSelector = innerSelector;
  98. this.listSelector = listSelector;
  99. this.itemSelector = itemSelector;
  100. this.linkSelector = linkSelector;
  101. this.buttonPreviousSelector = buttonPreviousSelector;
  102. this.buttonNextSelector = buttonNextSelector;
  103. this.caretSelector = caretSelector;
  104. this.megaSelector = megaSelector;
  105. this.subItemSelector = subItemSelector;
  106. this.maxLines = maxLines;
  107. this.maxLinesAttribute = maxLinesAttribute;
  108. this.labelOpenAttribute = labelOpenAttribute;
  109. this.labelCloseAttribute = labelCloseAttribute;
  110. this.attachClickListener = attachClickListener;
  111. this.attachHoverListener = attachHoverListener;
  112. this.attachFocusListener = attachFocusListener;
  113. this.attachKeyListener = attachKeyListener;
  114. this.attachResizeListener = attachResizeListener;
  115. this.onOpenCallback = onOpenCallback;
  116. this.onCloseCallback = onCloseCallback;
  117. // Private variables
  118. this.direction = 'ltr';
  119. this.open = null;
  120. this.close = null;
  121. this.toggleLabel = null;
  122. this.back = null;
  123. this.inner = null;
  124. this.itemsList = null;
  125. this.items = null;
  126. this.links = null;
  127. this.btnPrevious = null;
  128. this.btnNext = null;
  129. this.isOpen = false;
  130. this.resizeTimer = null;
  131. this.isKeyEvent = false;
  132. this.isDesktop = false;
  133. this.hasOverflow = false;
  134. this.offsetLeft = 0;
  135. this.lastVisibleItem = null;
  136. this.currentItem = null;
  137. this.totalItemsWidth = 0;
  138. this.breakpointL = 996;
  139. // Bind `this` for use in callbacks
  140. this.handleClickOnOpen = this.handleClickOnOpen.bind(this);
  141. this.handleClickOnClose = this.handleClickOnClose.bind(this);
  142. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  143. this.handleClickOnBack = this.handleClickOnBack.bind(this);
  144. this.handleClickOnNextItems = this.handleClickOnNextItems.bind(this);
  145. this.handleClickOnPreviousItems =
  146. this.handleClickOnPreviousItems.bind(this);
  147. this.handleClickOnCaret = this.handleClickOnCaret.bind(this);
  148. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  149. this.handleHoverOnItem = this.handleHoverOnItem.bind(this);
  150. this.handleHoverOffItem = this.handleHoverOffItem.bind(this);
  151. this.handleFocusIn = this.handleFocusIn.bind(this);
  152. this.handleFocusOut = this.handleFocusOut.bind(this);
  153. this.handleKeyboard = this.handleKeyboard.bind(this);
  154. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  155. this.handleResize = this.handleResize.bind(this);
  156. this.useDesktopDisplay = this.useDesktopDisplay.bind(this);
  157. this.checkMenuOverflow = this.checkMenuOverflow.bind(this);
  158. this.checkMenuItem = this.checkMenuItem.bind(this);
  159. this.checkMegaMenu = this.checkMegaMenu.bind(this);
  160. this.closeOpenDropdown = this.closeOpenDropdown.bind(this);
  161. this.positionMenuOverlay = this.positionMenuOverlay.bind(this);
  162. this.disableScroll = this.disableScroll.bind(this);
  163. this.enableScroll = this.enableScroll.bind(this);
  164. }
  165. /**
  166. * Initialise component.
  167. */
  168. init() {
  169. if (!ECL) {
  170. throw new TypeError('Called init but ECL is not present');
  171. }
  172. ECL.components = ECL.components || new Map();
  173. // Check display
  174. this.direction = getComputedStyle(this.element).direction;
  175. // Query elements
  176. this.open = queryOne(this.openSelector, this.element);
  177. this.close = queryOne(this.closeSelector, this.element);
  178. this.toggleLabel = queryOne('.ecl-link__label', this.open);
  179. this.back = queryOne(this.backSelector, this.element);
  180. this.inner = queryOne(this.innerSelector, this.element);
  181. this.itemsList = queryOne(this.listSelector, this.element);
  182. this.btnPrevious = queryOne(this.buttonPreviousSelector, this.element);
  183. this.btnNext = queryOne(this.buttonNextSelector, this.element);
  184. this.items = queryAll(this.itemSelector, this.element);
  185. this.subItems = queryAll(this.subItemSelector, this.element);
  186. this.links = queryAll(this.linkSelector, this.element);
  187. this.carets = queryAll(this.caretSelector, this.element);
  188. // Get extra parameter from markup
  189. const maxLinesMarkup = this.element.getAttribute(this.maxLinesAttribute);
  190. if (maxLinesMarkup) {
  191. this.maxLines = maxLinesMarkup;
  192. }
  193. // Check if we should use desktop display (it does not rely only on breakpoints)
  194. this.isDesktop = this.useDesktopDisplay();
  195. // Bind click events on buttons
  196. if (this.attachClickListener) {
  197. // Open
  198. if (this.open) {
  199. this.open.addEventListener('click', this.handleClickOnToggle);
  200. }
  201. // Close
  202. if (this.close) {
  203. this.close.addEventListener('click', this.handleClickOnClose);
  204. }
  205. // Back
  206. if (this.back) {
  207. this.back.addEventListener('click', this.handleClickOnBack);
  208. }
  209. // Previous items
  210. if (this.btnPrevious) {
  211. this.btnPrevious.addEventListener(
  212. 'click',
  213. this.handleClickOnPreviousItems,
  214. );
  215. }
  216. // Next items
  217. if (this.btnNext) {
  218. this.btnNext.addEventListener('click', this.handleClickOnNextItems);
  219. }
  220. // Global click
  221. if (this.attachClickListener) {
  222. document.addEventListener('click', this.handleClickGlobal);
  223. }
  224. }
  225. // Bind event on menu links
  226. if (this.links) {
  227. this.links.forEach((link) => {
  228. if (this.attachFocusListener) {
  229. link.addEventListener('focusin', this.closeOpenDropdown);
  230. link.addEventListener('focusin', this.handleFocusIn);
  231. link.addEventListener('focusout', this.handleFocusOut);
  232. }
  233. if (this.attachKeyListener) {
  234. link.addEventListener('keyup', this.handleKeyboard);
  235. }
  236. });
  237. }
  238. // Bind event on caret buttons
  239. if (this.carets) {
  240. this.carets.forEach((caret) => {
  241. if (this.attachFocusListener) {
  242. caret.addEventListener('focusin', this.handleFocusIn);
  243. caret.addEventListener('focusout', this.handleFocusOut);
  244. }
  245. if (this.attachKeyListener) {
  246. caret.addEventListener('keyup', this.handleKeyboard);
  247. }
  248. if (this.attachClickListener) {
  249. caret.addEventListener('click', this.handleClickOnCaret);
  250. }
  251. });
  252. }
  253. // Bind event on sub menu links
  254. if (this.subItems) {
  255. this.subItems.forEach((subItem) => {
  256. const subLink = queryOne('.ecl-menu__sublink', subItem);
  257. if (this.attachKeyListener && subLink) {
  258. subLink.addEventListener('keyup', this.handleKeyboard);
  259. }
  260. if (this.attachFocusListener && subLink) {
  261. subLink.addEventListener('focusout', this.handleFocusOut);
  262. }
  263. });
  264. }
  265. // Bind global keyboard events
  266. if (this.attachKeyListener) {
  267. document.addEventListener('keyup', this.handleKeyboardGlobal);
  268. }
  269. // Bind resize events
  270. if (this.attachResizeListener) {
  271. window.addEventListener('resize', this.handleResize);
  272. }
  273. // Browse first level items
  274. if (this.items) {
  275. this.items.forEach((item) => {
  276. // Check menu item display (right to left, full width, ...)
  277. this.checkMenuItem(item);
  278. this.totalItemsWidth += item.offsetWidth;
  279. if (item.hasAttribute('data-ecl-has-children')) {
  280. // Bind hover and focus events on menu items
  281. if (this.attachHoverListener) {
  282. item.addEventListener('mouseover', this.handleHoverOnItem);
  283. item.addEventListener('mouseout', this.handleHoverOffItem);
  284. }
  285. }
  286. });
  287. }
  288. this.positionMenuOverlay();
  289. // Update overflow display
  290. this.checkMenuOverflow();
  291. // Check if the current item is hidden (one side or the other)
  292. if (this.currentItem) {
  293. if (
  294. this.currentItem.getAttribute('data-ecl-menu-item-visible') === 'false'
  295. ) {
  296. this.btnNext.classList.add('ecl-menu__item--current');
  297. } else {
  298. this.btnPrevious.classList.add('ecl-menu__item--current');
  299. }
  300. }
  301. // Init sticky header
  302. this.stickyInstance = new Stickyfill.Sticky(this.element);
  303. // Hack to prevent css transition to be played on page load on chrome
  304. setTimeout(() => {
  305. this.element.classList.add('ecl-menu--transition');
  306. }, 500);
  307. // Set ecl initialized attribute
  308. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  309. ECL.components.set(this.element, this);
  310. }
  311. /**
  312. * Register a callback function for a specific event.
  313. *
  314. * @param {string} eventName - The name of the event to listen for.
  315. * @param {Function} callback - The callback function to be invoked when the event occurs.
  316. * @returns {void}
  317. * @memberof Menu
  318. * @instance
  319. *
  320. * @example
  321. * // Registering a callback for the 'onOpen' event
  322. * menu.on('onOpen', (event) => {
  323. * console.log('Open event occurred!', event);
  324. * });
  325. */
  326. on(eventName, callback) {
  327. this.eventManager.on(eventName, callback);
  328. }
  329. /**
  330. * Trigger a component event.
  331. *
  332. * @param {string} eventName - The name of the event to trigger.
  333. * @param {any} eventData - Data associated with the event.
  334. * @memberof Menu
  335. */
  336. trigger(eventName, eventData) {
  337. this.eventManager.trigger(eventName, eventData);
  338. }
  339. /**
  340. * Destroy component.
  341. */
  342. destroy() {
  343. if (this.stickyInstance) {
  344. this.stickyInstance.remove();
  345. }
  346. if (this.attachClickListener) {
  347. if (this.open) {
  348. this.open.removeEventListener('click', this.handleClickOnToggle);
  349. }
  350. if (this.close) {
  351. this.close.removeEventListener('click', this.handleClickOnClose);
  352. }
  353. if (this.back) {
  354. this.back.removeEventListener('click', this.handleClickOnBack);
  355. }
  356. if (this.btnPrevious) {
  357. this.btnPrevious.removeEventListener(
  358. 'click',
  359. this.handleClickOnPreviousItems,
  360. );
  361. }
  362. if (this.btnNext) {
  363. this.btnNext.removeEventListener('click', this.handleClickOnNextItems);
  364. }
  365. if (this.attachClickListener) {
  366. document.removeEventListener('click', this.handleClickGlobal);
  367. }
  368. }
  369. if (this.attachKeyListener && this.carets) {
  370. this.carets.forEach((caret) => {
  371. caret.removeEventListener('keyup', this.handleKeyboard);
  372. });
  373. }
  374. if (this.items && this.isDesktop) {
  375. this.items.forEach((item) => {
  376. if (item.hasAttribute('data-ecl-has-children')) {
  377. if (this.attachHoverListener) {
  378. item.removeEventListener('mouseover', this.handleHoverOnItem);
  379. item.removeEventListener('mouseout', this.handleHoverOffItem);
  380. }
  381. }
  382. });
  383. }
  384. if (this.links) {
  385. this.links.forEach((link) => {
  386. if (this.attachFocusListener) {
  387. link.removeEventListener('focusin', this.closeOpenDropdown);
  388. link.removeEventListener('focusin', this.handleFocusIn);
  389. link.removeEventListener('focusout', this.handleFocusOut);
  390. }
  391. if (this.attachKeyListener) {
  392. link.removeEventListener('keyup', this.handleKeyboard);
  393. }
  394. });
  395. }
  396. if (this.carets) {
  397. this.carets.forEach((caret) => {
  398. if (this.attachFocusListener) {
  399. caret.removeEventListener('focusin', this.handleFocusIn);
  400. caret.removeEventListener('focusout', this.handleFocusOut);
  401. }
  402. if (this.attachKeyListener) {
  403. caret.removeEventListener('keyup', this.handleKeyboard);
  404. }
  405. if (this.attachClickListener) {
  406. caret.removeEventListener('click', this.handleClickOnCaret);
  407. }
  408. });
  409. }
  410. if (this.subItems) {
  411. this.subItems.forEach((subItem) => {
  412. const subLink = queryOne('.ecl-menu__sublink', subItem);
  413. if (this.attachKeyListener && subLink) {
  414. subLink.removeEventListener('keyup', this.handleKeyboard);
  415. }
  416. if (this.attachFocusListener && subLink) {
  417. subLink.removeEventListener('focusout', this.handleFocusOut);
  418. }
  419. });
  420. }
  421. if (this.attachKeyListener) {
  422. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  423. }
  424. if (this.attachResizeListener) {
  425. window.removeEventListener('resize', this.handleResize);
  426. }
  427. if (this.element) {
  428. this.element.removeAttribute('data-ecl-auto-initialized');
  429. ECL.components.delete(this.element);
  430. }
  431. }
  432. /* eslint-disable class-methods-use-this */
  433. /**
  434. * Disable page scrolling
  435. */
  436. disableScroll() {
  437. document.body.classList.add('no-scroll');
  438. }
  439. /**
  440. * Enable page scrolling
  441. */
  442. enableScroll() {
  443. document.body.classList.remove('no-scroll');
  444. }
  445. /* eslint-enable class-methods-use-this */
  446. /**
  447. * Check if desktop display has to be used
  448. * - not using a phone or tablet (whatever the screen size is)
  449. * - not having hamburger menu on screen
  450. */
  451. useDesktopDisplay() {
  452. // Detect mobile devices
  453. if (isMobile.isMobileOnly) {
  454. return false;
  455. }
  456. // Force mobile display on tablet
  457. if (isMobile.isTablet) {
  458. this.element.classList.add('ecl-menu--forced-mobile');
  459. return false;
  460. }
  461. // After all that, check if the hamburger button is displayed
  462. if (window.innerWidth < this.breakpointL) {
  463. return false;
  464. }
  465. // Everything is fine to use desktop display
  466. this.element.classList.remove('ecl-menu--forced-mobile');
  467. return true;
  468. }
  469. /**
  470. * Trigger events on resize
  471. * Uses a debounce, for performance
  472. */
  473. handleResize() {
  474. // Scroll to top to ensure the menu is correctly positioned.
  475. document.documentElement.scrollTop = 0;
  476. document.body.scrollTop = 0;
  477. // Disable transition
  478. this.element.classList.remove('ecl-menu--transition');
  479. clearTimeout(this.resizeTimer);
  480. this.resizeTimer = setTimeout(() => {
  481. this.element.classList.remove('ecl-menu--forced-mobile');
  482. // Check global display
  483. this.isDesktop = this.useDesktopDisplay();
  484. // Update items display
  485. this.totalItemsWidth = 0;
  486. if (this.items) {
  487. this.items.forEach((item) => {
  488. this.checkMenuItem(item);
  489. this.totalItemsWidth += item.offsetWidth;
  490. });
  491. }
  492. // Update overflow display
  493. this.checkMenuOverflow();
  494. this.positionMenuOverlay();
  495. // Bring transition back
  496. this.element.classList.add('ecl-menu--transition');
  497. }, 200);
  498. return this;
  499. }
  500. /**
  501. * Dinamically set the position of the menu overlay
  502. */
  503. positionMenuOverlay() {
  504. const menuOverlay = queryOne('.ecl-menu__overlay', this.element);
  505. if (!this.isDesktop) {
  506. if (this.isOpen) {
  507. this.disableScroll();
  508. }
  509. setTimeout(() => {
  510. const header = queryOne('.ecl-site-header__header', document);
  511. if (header) {
  512. const position = header.getBoundingClientRect();
  513. const bottomPosition = Math.round(position.bottom);
  514. if (menuOverlay) {
  515. menuOverlay.style.top = `${bottomPosition}px`;
  516. }
  517. if (this.inner) {
  518. this.inner.style.top = `${bottomPosition}px`;
  519. }
  520. }
  521. }, 500);
  522. } else {
  523. this.enableScroll();
  524. if (this.inner) {
  525. this.inner.style.top = '';
  526. }
  527. if (menuOverlay) {
  528. menuOverlay.style.top = '';
  529. }
  530. }
  531. }
  532. /**
  533. * Check how to display menu horizontally and manage overflow
  534. */
  535. checkMenuOverflow() {
  536. // Backward compatibility
  537. if (!this.itemsList) {
  538. this.itemsList = queryOne('.ecl-menu__list', this.element);
  539. }
  540. if (
  541. !this.itemsList ||
  542. !this.inner ||
  543. !this.btnNext ||
  544. !this.btnPrevious ||
  545. !this.items
  546. ) {
  547. return;
  548. }
  549. // Check if the menu is too large
  550. // We take some margin for safety (same margin as the container's padding)
  551. this.hasOverflow = this.totalItemsWidth > this.inner.offsetWidth + 16;
  552. if (!this.hasOverflow || !this.isDesktop) {
  553. // Reset values related to overflow
  554. if (this.btnPrevious) {
  555. this.btnPrevious.style.display = 'none';
  556. }
  557. if (this.btnNext) {
  558. this.btnNext.style.display = 'none';
  559. }
  560. if (this.itemsList) {
  561. this.itemsList.style.left = '0';
  562. }
  563. if (this.inner) {
  564. this.inner.classList.remove('ecl-menu__inner--has-overflow');
  565. }
  566. this.offsetLeft = 0;
  567. this.totalItemsWidth = 0;
  568. this.lastVisibleItem = null;
  569. return;
  570. }
  571. if (this.inner) {
  572. this.inner.classList.add('ecl-menu__inner--has-overflow');
  573. }
  574. // Reset visibility indicator
  575. if (this.items) {
  576. this.items.forEach((item) => {
  577. item.removeAttribute('data-ecl-menu-item-visible');
  578. });
  579. }
  580. // First case: overflow to the end
  581. if (this.offsetLeft === 0) {
  582. this.btnNext.style.display = 'block';
  583. // Get visible items
  584. if (this.direction === 'rtl') {
  585. this.items.every((item) => {
  586. if (
  587. item.getBoundingClientRect().left <
  588. this.itemsList.getBoundingClientRect().left
  589. ) {
  590. this.lastVisibleItem = item;
  591. return false;
  592. }
  593. item.setAttribute('data-ecl-menu-item-visible', true);
  594. return true;
  595. });
  596. } else {
  597. this.items.every((item) => {
  598. if (
  599. item.getBoundingClientRect().right >
  600. this.itemsList.getBoundingClientRect().right
  601. ) {
  602. this.lastVisibleItem = item;
  603. return false;
  604. }
  605. item.setAttribute('data-ecl-menu-item-visible', true);
  606. return true;
  607. });
  608. }
  609. }
  610. // Second case: overflow to the begining
  611. else {
  612. // Get visible items
  613. // eslint-disable-next-line no-lonely-if
  614. if (this.direction === 'rtl') {
  615. this.items.forEach((item) => {
  616. if (
  617. item.getBoundingClientRect().right <=
  618. this.inner.getBoundingClientRect().right
  619. ) {
  620. item.setAttribute('data-ecl-menu-item-visible', true);
  621. }
  622. });
  623. } else {
  624. this.items.forEach((item) => {
  625. if (
  626. item.getBoundingClientRect().left >=
  627. this.inner.getBoundingClientRect().left
  628. ) {
  629. item.setAttribute('data-ecl-menu-item-visible', true);
  630. }
  631. });
  632. }
  633. }
  634. }
  635. /**
  636. * Check for a specific menu item how to display it:
  637. * - number of lines
  638. * - mega menu position
  639. *
  640. * @param {Node} menuItem
  641. */
  642. checkMenuItem(menuItem) {
  643. const menuLink = queryOne(this.linkSelector, menuItem);
  644. // Save current menu item
  645. if (menuItem.classList.contains('ecl-menu__item--current')) {
  646. this.currentItem = menuItem;
  647. }
  648. if (!this.isDesktop) {
  649. menuLink.style.width = 'auto';
  650. return;
  651. }
  652. // Check if line management has been disabled by user
  653. if (this.maxLines < 1) return;
  654. // Handle menu item height and width (n "lines" max)
  655. // Max height: n * line-height + padding
  656. // We need to temporally change item alignments to get the height
  657. menuItem.style.alignItems = 'flex-start';
  658. let linkWidth = menuLink.offsetWidth;
  659. const linkStyle = window.getComputedStyle(menuLink);
  660. const maxHeight =
  661. parseInt(linkStyle.lineHeight, 10) * this.maxLines +
  662. parseInt(linkStyle.paddingTop, 10) +
  663. parseInt(linkStyle.paddingBottom, 10);
  664. while (menuLink.offsetHeight > maxHeight) {
  665. menuLink.style.width = `${(linkWidth += 1)}px`;
  666. // Safety exit
  667. if (linkWidth > 1000) break;
  668. }
  669. menuItem.style.alignItems = 'unset';
  670. }
  671. /**
  672. * Handle positioning of mega menu
  673. * @param {Node} menuItem
  674. */
  675. checkMegaMenu(menuItem) {
  676. const menuMega = queryOne(this.megaSelector, menuItem);
  677. if (menuMega && this.inner) {
  678. // Check number of items and put them in column
  679. const subItems = queryAll(this.subItemSelector, menuMega);
  680. if (subItems.length < 5) {
  681. menuItem.classList.add('ecl-menu__item--col1');
  682. } else if (subItems.length < 9) {
  683. menuItem.classList.add('ecl-menu__item--col2');
  684. } else if (subItems.length < 13) {
  685. menuItem.classList.add('ecl-menu__item--col3');
  686. } else {
  687. menuItem.classList.add('ecl-menu__item--full');
  688. if (this.direction === 'rtl') {
  689. menuMega.style.right = `${this.offsetLeft}px`;
  690. } else {
  691. menuMega.style.left = `${this.offsetLeft}px`;
  692. }
  693. return;
  694. }
  695. // Check if there is enough space on the right to display the menu
  696. const megaBounding = menuMega.getBoundingClientRect();
  697. const containerBounding = this.inner.getBoundingClientRect();
  698. const menuItemBounding = menuItem.getBoundingClientRect();
  699. const megaWidth = megaBounding.width;
  700. const containerWidth = containerBounding.width;
  701. const menuItemPosition = menuItemBounding.left - containerBounding.left;
  702. if (menuItemPosition + megaWidth > containerWidth) {
  703. menuMega.classList.add('ecl-menu__mega--rtl');
  704. } else {
  705. menuMega.classList.remove('ecl-menu__mega--rtl');
  706. }
  707. }
  708. }
  709. /**
  710. * Handles keyboard events specific to the menu.
  711. *
  712. * @param {Event} e
  713. */
  714. handleKeyboard(e) {
  715. const element = e.target;
  716. const cList = element.classList;
  717. const menuExpanded = this.element.getAttribute('aria-expanded');
  718. const menuItem = element.closest(this.itemSelector);
  719. // Detect press on Escape
  720. if (e.key === 'Escape' || e.key === 'Esc') {
  721. if (document.activeElement === element) {
  722. element.blur();
  723. }
  724. if (menuExpanded === 'false') {
  725. const buttonCaret = queryOne('.ecl-menu__button-caret', menuItem);
  726. if (buttonCaret) {
  727. buttonCaret.focus();
  728. }
  729. this.closeOpenDropdown();
  730. }
  731. return;
  732. }
  733. // Key actions to toggle the caret buttons
  734. if (cList.contains('ecl-menu__button-caret') && menuExpanded === 'false') {
  735. if (e.keyCode === 32 || e.key === 'Enter') {
  736. if (menuItem.getAttribute('aria-expanded') === 'true') {
  737. this.handleHoverOffItem(e);
  738. } else {
  739. this.handleHoverOnItem(e);
  740. }
  741. return;
  742. }
  743. if (e.key === 'ArrowDown') {
  744. e.preventDefault();
  745. const firstItem = queryOne(
  746. '.ecl-menu__sublink:first-of-type',
  747. menuItem,
  748. );
  749. if (firstItem) {
  750. this.handleHoverOnItem(e);
  751. firstItem.focus();
  752. return;
  753. }
  754. }
  755. }
  756. // Key actions to navigate between first level menu items
  757. if (
  758. cList.contains('ecl-menu__link') ||
  759. cList.contains('ecl-menu__button-caret')
  760. ) {
  761. if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
  762. e.preventDefault();
  763. let prevItem = element.previousSibling;
  764. if (prevItem && prevItem.classList.contains('ecl-menu__link')) {
  765. prevItem.focus();
  766. return;
  767. }
  768. prevItem = element.parentElement.previousSibling;
  769. if (prevItem) {
  770. const prevClass = prevItem.classList.contains(
  771. 'ecl-menu__item--has-children',
  772. )
  773. ? '.ecl-menu__button-caret'
  774. : '.ecl-menu__link';
  775. const prevLink = queryOne(prevClass, prevItem);
  776. if (prevLink) {
  777. prevLink.focus();
  778. return;
  779. }
  780. }
  781. }
  782. if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
  783. e.preventDefault();
  784. let nextItem = element.nextSibling;
  785. if (nextItem && nextItem.classList.contains('ecl-menu__button-caret')) {
  786. nextItem.focus();
  787. return;
  788. }
  789. nextItem = element.parentElement.nextSibling;
  790. if (nextItem) {
  791. const nextLink = queryOne('.ecl-menu__link', nextItem);
  792. if (nextLink) {
  793. nextLink.focus();
  794. }
  795. }
  796. }
  797. this.closeOpenDropdown();
  798. }
  799. // Key actions to navigate between the sub-links
  800. if (cList.contains('ecl-menu__sublink')) {
  801. if (e.key === 'ArrowDown') {
  802. const nextItem = element.parentElement.nextSibling;
  803. if (nextItem) {
  804. const nextLink = queryOne('.ecl-menu__sublink', nextItem);
  805. if (nextLink) {
  806. nextLink.focus();
  807. return;
  808. }
  809. }
  810. }
  811. if (e.key === 'ArrowUp') {
  812. const prevItem = element.parentElement.previousSibling;
  813. if (prevItem) {
  814. const prevLink = queryOne('.ecl-menu__sublink', prevItem);
  815. if (prevLink) {
  816. prevLink.focus();
  817. }
  818. } else {
  819. const caretButton = queryOne(
  820. `${this.itemSelector}[aria-expanded="true"] ${this.caretSelector}`,
  821. this.element,
  822. );
  823. if (caretButton) {
  824. caretButton.focus();
  825. }
  826. }
  827. }
  828. }
  829. }
  830. /**
  831. * Handles global keyboard events, triggered outside of the menu.
  832. *
  833. * @param {Event} e
  834. */
  835. handleKeyboardGlobal(e) {
  836. const menuExpanded = this.element.getAttribute('aria-expanded');
  837. // Detect press on Escape
  838. if (e.key === 'Escape' || e.key === 'Esc') {
  839. if (menuExpanded === 'true') {
  840. this.handleClickOnClose();
  841. }
  842. this.items.forEach((item) => {
  843. item.setAttribute('aria-expanded', 'false');
  844. });
  845. this.carets.forEach((caret) => {
  846. caret.setAttribute('aria-expanded', 'false');
  847. });
  848. }
  849. }
  850. /**
  851. * Open menu list.
  852. * @param {Event} e
  853. *
  854. * @fires Menu#onOpen
  855. */
  856. handleClickOnOpen(e) {
  857. e.preventDefault();
  858. this.element.setAttribute('aria-expanded', 'true');
  859. this.inner.setAttribute('aria-hidden', 'false');
  860. this.disableScroll();
  861. this.isOpen = true;
  862. // Update label
  863. const closeLabel = this.element.getAttribute(this.labelCloseAttribute);
  864. if (this.toggleLabel && closeLabel) {
  865. this.toggleLabel.innerHTML = closeLabel;
  866. }
  867. this.trigger('onOpen', e);
  868. return this;
  869. }
  870. /**
  871. * Close menu list.
  872. * @param {Event} e
  873. *
  874. * @fires Menu#onClose
  875. */
  876. handleClickOnClose(e) {
  877. this.element.setAttribute('aria-expanded', 'false');
  878. // Remove css class and attribute from inner menu
  879. this.inner.classList.remove('ecl-menu__inner--expanded');
  880. this.inner.setAttribute('aria-hidden', 'true');
  881. // Remove css class and attribute from menu items
  882. this.items.forEach((item) => {
  883. item.classList.remove('ecl-menu__item--expanded');
  884. item.setAttribute('aria-expanded', 'false');
  885. });
  886. // Update label
  887. const openLabel = this.element.getAttribute(this.labelOpenAttribute);
  888. if (this.toggleLabel && openLabel) {
  889. this.toggleLabel.innerHTML = openLabel;
  890. }
  891. // Set focus to hamburger button
  892. if (this.open) {
  893. this.open.focus();
  894. }
  895. this.enableScroll();
  896. this.isOpen = false;
  897. this.trigger('onClose', e);
  898. return this;
  899. }
  900. /**
  901. * Toggle menu list.
  902. * @param {Event} e
  903. */
  904. handleClickOnToggle(e) {
  905. e.preventDefault();
  906. if (this.isOpen) {
  907. this.handleClickOnClose(e);
  908. } else {
  909. this.handleClickOnOpen(e);
  910. }
  911. }
  912. /**
  913. * Get back to previous list (on mobile)
  914. */
  915. handleClickOnBack() {
  916. // Remove css class from inner menu
  917. this.inner.classList.remove('ecl-menu__inner--expanded');
  918. // Remove css class and attribute from menu items
  919. this.items.forEach((item) => {
  920. item.classList.remove('ecl-menu__item--expanded');
  921. item.setAttribute('aria-expanded', 'false');
  922. });
  923. return this;
  924. }
  925. /**
  926. * Click on the previous items button
  927. */
  928. handleClickOnPreviousItems() {
  929. if (!this.itemsList || !this.btnNext) return;
  930. this.offsetLeft = 0;
  931. if (this.direction === 'rtl') {
  932. this.itemsList.style.right = '0';
  933. this.itemsList.style.left = 'auto';
  934. } else {
  935. this.itemsList.style.left = '0';
  936. this.itemsList.style.right = 'auto';
  937. }
  938. // Update button display
  939. this.btnPrevious.style.display = 'none';
  940. this.btnNext.style.display = 'block';
  941. // Refresh display
  942. if (this.items) {
  943. this.items.forEach((item) => {
  944. this.checkMenuItem(item);
  945. item.toggleAttribute('data-ecl-menu-item-visible');
  946. });
  947. }
  948. }
  949. /**
  950. * Click on the next items button
  951. */
  952. handleClickOnNextItems() {
  953. if (
  954. !this.itemsList ||
  955. !this.items ||
  956. !this.btnPrevious ||
  957. !this.lastVisibleItem
  958. )
  959. return;
  960. // Update button display
  961. this.btnPrevious.style.display = 'block';
  962. this.btnNext.style.display = 'none';
  963. // Calculate left offset
  964. if (this.direction === 'rtl') {
  965. this.offsetLeft =
  966. this.itemsList.getBoundingClientRect().right -
  967. this.lastVisibleItem.getBoundingClientRect().right -
  968. this.btnPrevious.offsetWidth;
  969. this.itemsList.style.right = `-${this.offsetLeft}px`;
  970. this.itemsList.style.left = 'auto';
  971. } else {
  972. this.offsetLeft =
  973. this.lastVisibleItem.getBoundingClientRect().left -
  974. this.itemsList.getBoundingClientRect().left -
  975. this.btnPrevious.offsetWidth;
  976. this.itemsList.style.left = `-${this.offsetLeft}px`;
  977. this.itemsList.style.right = 'auto';
  978. }
  979. // Refresh display
  980. if (this.items) {
  981. this.items.forEach((item) => {
  982. this.checkMenuItem(item);
  983. item.toggleAttribute('data-ecl-menu-item-visible');
  984. });
  985. }
  986. }
  987. /**
  988. * Click on a menu item caret
  989. * @param {Event} e
  990. */
  991. handleClickOnCaret(e) {
  992. // Don't execute for desktop display
  993. const menuExpanded = this.element.getAttribute('aria-expanded');
  994. if (menuExpanded === 'false') {
  995. return;
  996. }
  997. // Add css class to inner menu
  998. this.inner.classList.add('ecl-menu__inner--expanded');
  999. // Add css class and attribute to current item, and remove it from others
  1000. const menuItem = e.target.closest(this.itemSelector);
  1001. this.items.forEach((item) => {
  1002. if (item === menuItem) {
  1003. item.classList.add('ecl-menu__item--expanded');
  1004. item.setAttribute('aria-expanded', 'true');
  1005. } else {
  1006. item.classList.remove('ecl-menu__item--expanded');
  1007. item.setAttribute('aria-expanded', 'false');
  1008. }
  1009. });
  1010. this.checkMegaMenu(menuItem);
  1011. }
  1012. /**
  1013. * Hover on a menu item
  1014. * @param {Event} e
  1015. */
  1016. handleHoverOnItem(e) {
  1017. const menuItem = e.target.closest(this.itemSelector);
  1018. // Ignore hidden or partially hidden items
  1019. if (
  1020. this.hasOverflow &&
  1021. !menuItem.hasAttribute('data-ecl-menu-item-visible')
  1022. )
  1023. return;
  1024. // Add attribute to current item, and remove it from others
  1025. this.items.forEach((item) => {
  1026. const caretButton = queryOne(this.caretSelector, item);
  1027. if (item === menuItem) {
  1028. item.setAttribute('aria-expanded', 'true');
  1029. if (caretButton) {
  1030. caretButton.setAttribute('aria-expanded', 'true');
  1031. }
  1032. } else {
  1033. item.setAttribute('aria-expanded', 'false');
  1034. // Force remove focus on caret buttons
  1035. if (caretButton) {
  1036. caretButton.setAttribute('aria-expanded', 'false');
  1037. caretButton.blur();
  1038. }
  1039. }
  1040. });
  1041. this.checkMegaMenu(menuItem);
  1042. }
  1043. /**
  1044. * Deselect a menu item
  1045. * @param {Event} e
  1046. */
  1047. handleHoverOffItem(e) {
  1048. // Remove attribute to current item
  1049. const menuItem = e.target.closest(this.itemSelector);
  1050. menuItem.setAttribute('aria-expanded', 'false');
  1051. const caretButton = queryOne(this.caretSelector, menuItem);
  1052. if (caretButton) {
  1053. caretButton.setAttribute('aria-expanded', 'false');
  1054. }
  1055. return this;
  1056. }
  1057. /**
  1058. * Deselect any opened menu item
  1059. */
  1060. closeOpenDropdown() {
  1061. const currentItem = queryOne(
  1062. `${this.itemSelector}[aria-expanded='true']`,
  1063. this.element,
  1064. );
  1065. if (currentItem) {
  1066. currentItem.setAttribute('aria-expanded', 'false');
  1067. const caretButton = queryOne(this.caretSelector, currentItem);
  1068. if (caretButton) {
  1069. caretButton.setAttribute('aria-expanded', 'false');
  1070. }
  1071. }
  1072. }
  1073. /**
  1074. * Focus in a menu link
  1075. * @param {Event} e
  1076. */
  1077. handleFocusIn(e) {
  1078. const element = e.target;
  1079. // Specific focus action for desktop menu
  1080. if (this.isDesktop && this.hasOverflow) {
  1081. const parentItem = element.closest('[data-ecl-menu-item]');
  1082. if (!parentItem.hasAttribute('data-ecl-menu-item-visible')) {
  1083. // Trigger scroll button depending on the context
  1084. if (this.offsetLeft === 0) {
  1085. this.handleClickOnNextItems();
  1086. } else {
  1087. this.handleClickOnPreviousItems();
  1088. }
  1089. }
  1090. }
  1091. }
  1092. /**
  1093. * Focus out of a menu link
  1094. * @param {Event} e
  1095. */
  1096. handleFocusOut(e) {
  1097. const element = e.target;
  1098. const menuExpanded = this.element.getAttribute('aria-expanded');
  1099. // Specific focus action for mobile menu
  1100. // Loop through the items and go back to close button
  1101. if (menuExpanded === 'true') {
  1102. const nextItem = element.parentElement.nextSibling;
  1103. if (!nextItem) {
  1104. // There are no next menu item, but maybe there is a carret button
  1105. const caretButton = queryOne(
  1106. '.ecl-menu__button-caret',
  1107. element.parentElement,
  1108. );
  1109. if (caretButton && element !== caretButton) {
  1110. return;
  1111. }
  1112. // This is the last item, go back to close button
  1113. this.close.focus();
  1114. }
  1115. }
  1116. }
  1117. /**
  1118. * Handles global click events, triggered outside of the menu.
  1119. *
  1120. * @param {Event} e
  1121. */
  1122. handleClickGlobal(e) {
  1123. // Check if the menu is open
  1124. if (this.isOpen) {
  1125. // Check if the click occured in the menu
  1126. if (!this.inner.contains(e.target) && !this.open.contains(e.target)) {
  1127. this.handleClickOnClose(e);
  1128. }
  1129. }
  1130. }
  1131. }
  1132. export default Menu;