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