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