tabs.js

  1. import { queryOne, queryAll } from '@ecl/dom-utils';
  2. import EventManager from '@ecl/event-manager';
  3. /**
  4. * @param {HTMLElement} element DOM element for component instantiation and scope
  5. * @param {Object} options
  6. * @param {String} options.containerSelector Selector for container element
  7. * @param {String} options.listSelector Selector for list element
  8. * @param {String} options.listItemsSelector Selector for tabs element
  9. * @param {String} options.moreButtonSelector Selector for more button element
  10. * @param {String} options.moreLabelSelector Selector for more button label element
  11. * @param {String} options.prevSelector Selector for prev element
  12. * @param {String} options.nextSelector Selector for next element
  13. * @param {Boolean} options.attachClickListener
  14. * @param {Boolean} options.attachResizeListener
  15. */
  16. export class Tabs {
  17. /**
  18. * @static
  19. * Shorthand for instance creation and initialisation.
  20. *
  21. * @param {HTMLElement} root DOM element for component instantiation and scope
  22. *
  23. * @return {Tabs} An instance of Tabs.
  24. */
  25. static autoInit(root, { TABS: defaultOptions = {} } = {}) {
  26. const tabs = new Tabs(root, defaultOptions);
  27. tabs.init();
  28. root.ECLTabs = tabs;
  29. return tabs;
  30. }
  31. /**
  32. * An array of supported events for this component.
  33. *
  34. * @type {Array<string>}
  35. * @memberof Select
  36. */
  37. supportedEvents = ['onToggle'];
  38. constructor(
  39. element,
  40. {
  41. containerSelector = '.ecl-tabs__container',
  42. listSelector = '.ecl-tabs__list',
  43. listItemsSelector = '.ecl-tabs__item:not(.ecl-tabs__item--more)',
  44. moreItemSelector = '.ecl-tabs__item--more',
  45. moreButtonSelector = '.ecl-tabs__toggle',
  46. moreLabelSelector = '.ecl-tabs__toggle .ecl-button__label',
  47. prevSelector = '.ecl-tabs__prev',
  48. nextSelector = '.ecl-tabs__next',
  49. attachClickListener = true,
  50. attachResizeListener = true,
  51. } = {},
  52. ) {
  53. // Check element
  54. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  55. throw new TypeError(
  56. 'DOM element should be given to initialize this widget.',
  57. );
  58. }
  59. this.element = element;
  60. this.eventManager = new EventManager();
  61. // Options
  62. this.containerSelector = containerSelector;
  63. this.listSelector = listSelector;
  64. this.listItemsSelector = listItemsSelector;
  65. this.moreItemSelector = moreItemSelector;
  66. this.moreButtonSelector = moreButtonSelector;
  67. this.moreLabelSelector = moreLabelSelector;
  68. this.prevSelector = prevSelector;
  69. this.nextSelector = nextSelector;
  70. this.attachClickListener = attachClickListener;
  71. this.attachResizeListener = attachResizeListener;
  72. // Private variables
  73. this.container = null;
  74. this.list = null;
  75. this.listItems = null;
  76. this.moreItem = null;
  77. this.moreButton = null;
  78. this.moreButtonActive = false;
  79. this.moreLabel = null;
  80. this.moreLabelValue = null;
  81. this.dropdown = null;
  82. this.dropdownList = null;
  83. this.dropdownItems = null;
  84. this.allowShift = true;
  85. this.buttonNextSize = 0;
  86. this.index = 0;
  87. this.total = 0;
  88. this.tabsKey = [];
  89. this.firstTab = null;
  90. this.lastTab = null;
  91. this.direction = 'ltr';
  92. this.isMobile = false;
  93. this.resizeTimer = null;
  94. // Bind `this` for use in callbacks
  95. this.handleClickOnToggle = this.handleClickOnToggle.bind(this);
  96. this.handleResize = this.handleResize.bind(this);
  97. this.closeMoreDropdown = this.closeMoreDropdown.bind(this);
  98. this.shiftTabs = this.shiftTabs.bind(this);
  99. this.handleKeyboardOnTabs = this.handleKeyboardOnTabs.bind(this);
  100. this.moveFocus = this.moveFocus.bind(this);
  101. this.arrowFocusToTab = this.arrowFocusToTab.bind(this);
  102. this.tabsKeyEvents = this.tabsKeyEvents.bind(this);
  103. }
  104. /**
  105. * Initialise component.
  106. */
  107. init() {
  108. if (!ECL) {
  109. throw new TypeError('Called init but ECL is not present');
  110. }
  111. ECL.components = ECL.components || new Map();
  112. this.container = queryOne(this.containerSelector, this.element);
  113. this.list = queryOne(this.listSelector, this.element);
  114. this.listItems = queryAll(this.listItemsSelector, this.element);
  115. this.moreItem = queryOne(this.moreItemSelector, this.element);
  116. this.moreButton = queryOne(this.moreButtonSelector, this.element);
  117. this.moreLabel = queryOne(this.moreLabelSelector, this.element);
  118. this.moreLabelValue = this.moreLabel.innerText;
  119. this.btnPrev = queryOne(this.prevSelector, this.element);
  120. this.btnNext = queryOne(this.nextSelector, this.element);
  121. this.total = this.listItems.length;
  122. if (this.moreButton) {
  123. // Create the "more" dropdown and clone existing list items
  124. this.dropdown = document.createElement('div');
  125. this.dropdown.classList.add('ecl-tabs__dropdown');
  126. this.dropdownList = document.createElement('div');
  127. this.dropdownList.classList.add('ecl-tabs__dropdown-list');
  128. this.listItems.forEach((item) => {
  129. this.dropdownList.appendChild(item.cloneNode(true));
  130. });
  131. this.dropdown.appendChild(this.dropdownList);
  132. this.moreItem.appendChild(this.dropdown);
  133. this.dropdownItems = queryAll(
  134. '.ecl-tabs__dropdown .ecl-tabs__item',
  135. this.element,
  136. );
  137. }
  138. if (this.btnNext) {
  139. this.buttonNextSize = this.btnNext.getBoundingClientRect().width;
  140. }
  141. this.handleResize();
  142. // Bind events
  143. if (this.attachClickListener && this.moreButton) {
  144. this.moreButton.addEventListener('click', this.handleClickOnToggle);
  145. }
  146. if (this.attachClickListener && document && this.moreButton) {
  147. document.addEventListener('click', this.closeMoreDropdown);
  148. }
  149. if (this.attachClickListener && this.btnNext) {
  150. this.btnNext.addEventListener(
  151. 'click',
  152. this.shiftTabs.bind(this, 'next', true),
  153. );
  154. }
  155. if (this.attachClickListener && this.btnPrev) {
  156. this.btnPrev.addEventListener(
  157. 'click',
  158. this.shiftTabs.bind(this, 'prev', true),
  159. );
  160. }
  161. if (this.attachResizeListener) {
  162. window.addEventListener('resize', this.handleResize);
  163. }
  164. // Set ecl initialized attribute
  165. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  166. ECL.components.set(this.element, this);
  167. }
  168. /**
  169. * Register a callback function for a specific event.
  170. *
  171. * @param {string} eventName - The name of the event to listen for.
  172. * @param {Function} callback - The callback function to be invoked when the event occurs.
  173. * @returns {void}
  174. * @memberof Tabs
  175. * @instance
  176. *
  177. * @example
  178. * // Registering a callback for the 'onToggle' event
  179. * inpage.on('onToggle', (event) => {
  180. * console.log('Toggle event occurred!', event);
  181. * });
  182. */
  183. on(eventName, callback) {
  184. this.eventManager.on(eventName, callback);
  185. }
  186. /**
  187. * Trigger a component event.
  188. *
  189. * @param {string} eventName - The name of the event to trigger.
  190. * @param {any} eventData - Data associated with the event.
  191. *
  192. * @memberof Tabs
  193. */
  194. trigger(eventName, eventData) {
  195. this.eventManager.trigger(eventName, eventData);
  196. }
  197. /**
  198. * Destroy component.
  199. */
  200. destroy() {
  201. if (this.dropdown) {
  202. this.dropdown.remove();
  203. }
  204. if (this.moreButton) {
  205. this.moreLabel.textContent = this.moreLabelValue;
  206. this.moreButton.replaceWith(this.moreButton.cloneNode(true));
  207. }
  208. if (this.btnNext) {
  209. this.btnNext.replaceWith(this.btnNext.cloneNode(true));
  210. }
  211. if (this.btnPrev) {
  212. this.btnPrev.replaceWith(this.btnPrev.cloneNode(true));
  213. }
  214. if (this.attachClickListener && document && this.moreButton) {
  215. document.removeEventListener('click', this.closeMoreDropdown);
  216. }
  217. if (this.attachResizeListener) {
  218. window.removeEventListener('resize', this.handleResize);
  219. }
  220. if (this.tabsKey) {
  221. this.tabsKey.forEach((item) => {
  222. item.addEventListener('keydown', this.handleKeyboardOnTabs);
  223. });
  224. }
  225. if (this.element) {
  226. this.element.removeAttribute('data-ecl-auto-initialized');
  227. ECL.components.delete(this.element);
  228. }
  229. }
  230. /**
  231. * Action to shift next or previous tabs on mobile format.
  232. * @param {int|string} dir
  233. */
  234. shiftTabs(dir) {
  235. this.index = dir === 'next' ? this.index + 1 : this.index - 1;
  236. // Show or hide prev or next button based on tab index
  237. if (this.index >= 1) {
  238. this.btnPrev.style.display = 'flex';
  239. this.container.classList.add('ecl-tabs__container--left');
  240. } else {
  241. this.btnPrev.style.display = 'none';
  242. this.container.classList.remove('ecl-tabs__container--left');
  243. }
  244. if (this.index >= this.total - 1) {
  245. this.btnNext.style.display = 'none';
  246. this.container.classList.remove('ecl-tabs__container--right');
  247. } else {
  248. this.btnNext.style.display = 'flex';
  249. this.container.classList.add('ecl-tabs__container--right');
  250. }
  251. // Slide tabs
  252. let newOffset = 0;
  253. this.direction = getComputedStyle(this.element).direction;
  254. if (this.direction === 'rtl') {
  255. newOffset = Math.ceil(
  256. this.list.offsetWidth -
  257. this.listItems[this.index].offsetLeft -
  258. this.listItems[this.index].offsetWidth,
  259. );
  260. } else {
  261. newOffset = Math.ceil(this.listItems[this.index].offsetLeft);
  262. }
  263. const maxScroll = Math.ceil(
  264. this.list.getBoundingClientRect().width -
  265. this.element.getBoundingClientRect().width,
  266. );
  267. if (newOffset > maxScroll) {
  268. this.btnNext.style.display = 'none';
  269. this.container.classList.remove('ecl-tabs__container--right');
  270. newOffset = maxScroll;
  271. }
  272. this.list.style.transitionDuration = '0.4s';
  273. if (this.direction === 'rtl') {
  274. this.list.style.transform = `translate3d(${newOffset}px, 0px, 0px)`;
  275. } else {
  276. this.list.style.transform = `translate3d(-${newOffset}px, 0px, 0px)`;
  277. }
  278. }
  279. /**
  280. * Toggle the "more" dropdown.
  281. */
  282. handleClickOnToggle(e) {
  283. this.dropdown.classList.toggle('ecl-tabs__dropdown--show');
  284. this.moreButton.setAttribute(
  285. 'aria-expanded',
  286. this.dropdown.classList.contains('ecl-tabs__dropdown--show'),
  287. );
  288. this.trigger('onToggle', e);
  289. }
  290. /**
  291. * Sets the callback function to be executed on toggle.
  292. * @param {Function} callback - The callback function to be set.
  293. */
  294. set onToggle(callback) {
  295. this.onToggleCallback = callback;
  296. }
  297. /**
  298. * Gets the callback function set for toggle events.
  299. * @returns {Function|null} - The callback function, or null if not set.
  300. */
  301. get onToggle() {
  302. return this.onToggleCallback;
  303. }
  304. /**
  305. * Trigger events on resize.
  306. */
  307. handleResize() {
  308. // Close dropdown if more button is not displayed
  309. if (window.getComputedStyle(this.moreButton).display === 'none') {
  310. this.closeMoreDropdown(this);
  311. }
  312. clearTimeout(this.resizeTimer);
  313. this.resizeTimer = setTimeout(() => {
  314. this.list.style.transform = `translate3d(0px, 0px, 0px)`;
  315. // Behaviors for mobile format
  316. const vw = Math.max(
  317. document.documentElement.clientWidth || 0,
  318. window.innerWidth || 0,
  319. );
  320. if (vw <= 480) {
  321. this.isMobile = true;
  322. this.index = 1;
  323. this.list.style.transitionDuration = '0.4s';
  324. this.shiftTabs(this.index);
  325. if (this.moreItem) {
  326. this.moreItem.classList.add('ecl-tabs__item--hidden');
  327. }
  328. if (this.moreButton) {
  329. this.moreButton.classList.add('ecl-tabs__toggle--hidden');
  330. }
  331. let listWidth = 0;
  332. this.listItems.forEach((item) => {
  333. item.classList.remove('ecl-tabs__item--hidden');
  334. listWidth += Math.ceil(item.getBoundingClientRect().width);
  335. });
  336. this.list.style.width = `${listWidth}px`;
  337. this.btnNext.style.display = 'flex';
  338. this.container.classList.add('ecl-tabs__container--right');
  339. this.btnPrev.style.display = 'none';
  340. this.container.classList.remove('ecl-tabs__container--left');
  341. this.tabsKeyEvents();
  342. return;
  343. }
  344. this.isMobile = false;
  345. // Behaviors for Tablet and desktop format (More button)
  346. this.btnNext.style.display = 'none';
  347. this.container.classList.remove('ecl-tabs__container--right');
  348. this.btnPrev.style.display = 'none';
  349. this.container.classList.remove('ecl-tabs__container--left');
  350. this.list.style.width = 'auto';
  351. // Hide items that won't fit in the list
  352. let stopWidth = this.moreButton.getBoundingClientRect().width + 25;
  353. const hiddenItems = [];
  354. const listWidth = this.list.getBoundingClientRect().width;
  355. this.moreButtonActive = false;
  356. this.listItems.forEach((item, i) => {
  357. item.classList.remove('ecl-tabs__item--hidden');
  358. if (
  359. listWidth >= stopWidth + item.getBoundingClientRect().width &&
  360. !hiddenItems.includes(i - 1)
  361. ) {
  362. stopWidth += item.getBoundingClientRect().width;
  363. } else {
  364. item.classList.add('ecl-tabs__item--hidden');
  365. if (item.childNodes[0].classList.contains('ecl-tabs__link--active')) {
  366. this.moreButtonActive = true;
  367. }
  368. hiddenItems.push(i);
  369. }
  370. });
  371. // Add active class to the more button if it contains an active element
  372. if (this.moreButtonActive) {
  373. this.moreButton.classList.add('ecl-tabs__toggle--active');
  374. } else {
  375. this.moreButton.classList.remove('ecl-tabs__toggle--active');
  376. }
  377. // Toggle the visibility of More button and items in dropdown
  378. if (!hiddenItems.length) {
  379. this.moreItem.classList.add('ecl-tabs__item--hidden');
  380. this.moreButton.classList.add('ecl-tabs__toggle--hidden');
  381. } else {
  382. this.moreItem.classList.remove('ecl-tabs__item--hidden');
  383. this.moreButton.classList.remove('ecl-tabs__toggle--hidden');
  384. this.moreLabel.textContent = this.moreLabelValue.replace(
  385. '%d',
  386. hiddenItems.length,
  387. );
  388. this.dropdownItems.forEach((item, i) => {
  389. if (!hiddenItems.includes(i)) {
  390. item.classList.add('ecl-tabs__item--hidden');
  391. } else {
  392. item.classList.remove('ecl-tabs__item--hidden');
  393. }
  394. });
  395. }
  396. this.tabsKeyEvents();
  397. }, 100);
  398. }
  399. /**
  400. * Bind key events on tabs for accessibility.
  401. */
  402. tabsKeyEvents() {
  403. this.tabsKey = [];
  404. this.listItems.forEach((item, index, array) => {
  405. let tab = null;
  406. if (!item.classList.contains('ecl-tabs__item--hidden')) {
  407. tab = queryOne('.ecl-tabs__link', item);
  408. } else {
  409. const dropdownItem = this.dropdownItems[index];
  410. tab = queryOne('.ecl-tabs__link', dropdownItem);
  411. }
  412. tab.addEventListener('keydown', this.handleKeyboardOnTabs);
  413. this.tabsKey.push(tab);
  414. if (index === 0) {
  415. this.firstTab = tab;
  416. }
  417. if (index === array.length - 1) {
  418. this.lastTab = tab;
  419. }
  420. });
  421. }
  422. /**
  423. * Close the dropdown.
  424. * @param {Event} e
  425. */
  426. closeMoreDropdown(e) {
  427. let el = e.target;
  428. while (el) {
  429. if (el === this.moreButton) {
  430. return;
  431. }
  432. el = el.parentNode;
  433. }
  434. this.moreButton.setAttribute('aria-expanded', false);
  435. this.dropdown.classList.remove('ecl-tabs__dropdown--show');
  436. }
  437. /**
  438. * @param {Event} e
  439. */
  440. handleKeyboardOnTabs(e) {
  441. const tgt = e.currentTarget;
  442. switch (e.key) {
  443. case 'ArrowLeft':
  444. case 'ArrowUp':
  445. this.arrowFocusToTab(tgt, 'prev');
  446. break;
  447. case 'ArrowRight':
  448. case 'ArrowDown':
  449. this.arrowFocusToTab(tgt, 'next');
  450. break;
  451. case 'Home':
  452. this.moveFocus(this.firstTab);
  453. break;
  454. case 'End':
  455. this.moveFocus(this.lastTab);
  456. break;
  457. default:
  458. }
  459. }
  460. /**
  461. * @param {HTMLElement} currentTab tab element
  462. */
  463. moveFocus(currentTab) {
  464. if (currentTab.closest('.ecl-tabs__dropdown')) {
  465. this.moreButton.setAttribute('aria-expanded', true);
  466. this.dropdown.classList.add('ecl-tabs__dropdown--show');
  467. } else {
  468. this.moreButton.setAttribute('aria-expanded', false);
  469. this.dropdown.classList.remove('ecl-tabs__dropdown--show');
  470. }
  471. currentTab.focus();
  472. }
  473. /**
  474. * @param {HTMLElement} currentTab tab element
  475. * @param {string} direction key arrow direction
  476. */
  477. arrowFocusToTab(currentTab, direction) {
  478. let index = this.tabsKey.indexOf(currentTab);
  479. index = direction === 'next' ? index + 1 : index - 1;
  480. const startTab = direction === 'next' ? this.firstTab : this.lastTab;
  481. const endTab = direction === 'next' ? this.lastTab : this.firstTab;
  482. if (this.isMobile) {
  483. if (currentTab !== endTab) {
  484. this.moveFocus(this.tabsKey[index]);
  485. this.shiftTabs(direction);
  486. }
  487. return;
  488. }
  489. if (currentTab === endTab) {
  490. this.moveFocus(startTab);
  491. } else {
  492. this.moveFocus(this.tabsKey[index]);
  493. }
  494. }
  495. }
  496. export default Tabs;