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