menu.js

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