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