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.moreLabelSelector Selector for more list item element
  10. * @param {String} options.moreButtonSelector Selector for more button element
  11. * @param {String} options.moreLabelSelector Selector for more button label element
  12. * @param {String} options.prevSelector Selector for prev element
  13. * @param {String} options.nextSelector Selector for next element
  14. * @param {Boolean} options.attachClickListener
  15. * @param {Boolean} options.attachResizeListener
  16. */
  17. export class Tabs {
  18. /**
  19. * @static
  20. * Shorthand for instance creation and initialisation.
  21. *
  22. * @param {HTMLElement} root DOM element for component instantiation and scope
  23. *
  24. * @return {Tabs} An instance of Tabs.
  25. */
  26. static autoInit(root, { TABS: defaultOptions = {} } = {}) {
  27. const tabs = new Tabs(root, defaultOptions);
  28. tabs.init();
  29. root.ECLTabs = tabs;
  30. return tabs;
  31. }
  32. /**
  33. * An array of supported events for this component.
  34. *
  35. * @type {Array<string>}
  36. * @memberof Select
  37. */
  38. supportedEvents = ['onToggle'];
  39. constructor(
  40. element,
  41. {
  42. containerSelector = '.ecl-tabs__container',
  43. listSelector = '.ecl-tabs__list',
  44. listItemsSelector = '.ecl-tabs__item:not(.ecl-tabs__item--more)',
  45. moreItemSelector = '.ecl-tabs__item--more',
  46. moreButtonSelector = '.ecl-tabs__toggle',
  47. moreLabelSelector = '.ecl-tabs__toggle .ecl-button__label',
  48. prevSelector = '.ecl-tabs__prev',
  49. nextSelector = '.ecl-tabs__next',
  50. attachClickListener = true,
  51. attachResizeListener = true,
  52. } = {},
  53. ) {
  54. // Check element
  55. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  56. throw new TypeError(
  57. 'DOM element should be given to initialize this widget.',
  58. );
  59. }
  60. this.element = element;
  61. this.eventManager = new EventManager();
  62. // Options
  63. this.containerSelector = containerSelector;
  64. this.listSelector = listSelector;
  65. this.listItemsSelector = listItemsSelector;
  66. this.moreItemSelector = moreItemSelector;
  67. this.moreButtonSelector = moreButtonSelector;
  68. this.moreLabelSelector = moreLabelSelector;
  69. this.prevSelector = prevSelector;
  70. this.nextSelector = nextSelector;
  71. this.attachClickListener = attachClickListener;
  72. this.attachResizeListener = attachResizeListener;
  73. // Private variables
  74. this.container = null;
  75. this.list = null;
  76. this.listItems = null;
  77. this.moreItem = null;
  78. this.moreButton = null;
  79. this.moreButtonActive = false;
  80. this.moreLabel = null;
  81. this.moreLabelValue = null;
  82. this.dropdown = null;
  83. this.dropdownList = null;
  84. this.dropdownItems = null;
  85. this.allowShift = true;
  86. this.buttonNextSize = 0;
  87. this.index = 0;
  88. this.total = 0;
  89. this.tabsKey = [];
  90. this.firstTab = null;
  91. this.lastTab = null;
  92. this.direction = 'ltr';
  93. this.isMobile = false;
  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 = 'block';
  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 = 'block';
  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. this.list.style.transform = `translate3d(0px, 0px, 0px)`;
  313. // Behaviors for mobile format
  314. const vw = Math.max(
  315. document.documentElement.clientWidth || 0,
  316. window.innerWidth || 0,
  317. );
  318. if (vw <= 480) {
  319. this.isMobile = true;
  320. this.index = 1;
  321. this.list.style.transitionDuration = '0.4s';
  322. this.shiftTabs(this.index);
  323. if (this.moreItem) {
  324. this.moreItem.classList.add('ecl-tabs__item--hidden');
  325. }
  326. if (this.moreButton) {
  327. this.moreButton.classList.add('ecl-tabs__toggle--hidden');
  328. }
  329. let listWidth = 0;
  330. this.listItems.forEach((item) => {
  331. item.classList.remove('ecl-tabs__item--hidden');
  332. listWidth += Math.ceil(item.getBoundingClientRect().width);
  333. });
  334. this.list.style.width = `${listWidth}px`;
  335. this.btnNext.style.display = 'block';
  336. this.container.classList.add('ecl-tabs__container--right');
  337. this.btnPrev.style.display = 'none';
  338. this.container.classList.remove('ecl-tabs__container--left');
  339. this.tabsKeyEvents();
  340. return;
  341. }
  342. this.isMobile = false;
  343. // Behaviors for Tablet and desktop format (More button)
  344. this.btnNext.style.display = 'none';
  345. this.container.classList.remove('ecl-tabs__container--right');
  346. this.btnPrev.style.display = 'none';
  347. this.container.classList.remove('ecl-tabs__container--left');
  348. this.list.style.width = 'auto';
  349. // Hide items that won't fit in the list
  350. let stopWidth = this.moreButton.getBoundingClientRect().width + 25;
  351. const hiddenItems = [];
  352. const listWidth = this.list.getBoundingClientRect().width;
  353. this.moreButtonActive = false;
  354. this.listItems.forEach((item, i) => {
  355. item.classList.remove('ecl-tabs__item--hidden');
  356. if (
  357. listWidth >= stopWidth + item.getBoundingClientRect().width &&
  358. !hiddenItems.includes(i - 1)
  359. ) {
  360. stopWidth += item.getBoundingClientRect().width;
  361. } else {
  362. item.classList.add('ecl-tabs__item--hidden');
  363. if (item.childNodes[0].classList.contains('ecl-tabs__link--active')) {
  364. this.moreButtonActive = true;
  365. }
  366. hiddenItems.push(i);
  367. }
  368. });
  369. // Add active class to the more button if it contains an active element
  370. if (this.moreButtonActive) {
  371. this.moreButton.classList.add('ecl-tabs__toggle--active');
  372. } else {
  373. this.moreButton.classList.remove('ecl-tabs__toggle--active');
  374. }
  375. // Toggle the visibility of More button and items in dropdown
  376. if (!hiddenItems.length) {
  377. this.moreItem.classList.add('ecl-tabs__item--hidden');
  378. this.moreButton.classList.add('ecl-tabs__toggle--hidden');
  379. } else {
  380. this.moreItem.classList.remove('ecl-tabs__item--hidden');
  381. this.moreButton.classList.remove('ecl-tabs__toggle--hidden');
  382. this.moreLabel.textContent = this.moreLabelValue.replace(
  383. '%d',
  384. hiddenItems.length,
  385. );
  386. this.dropdownItems.forEach((item, i) => {
  387. if (!hiddenItems.includes(i)) {
  388. item.classList.add('ecl-tabs__item--hidden');
  389. } else {
  390. item.classList.remove('ecl-tabs__item--hidden');
  391. }
  392. });
  393. }
  394. this.tabsKeyEvents();
  395. }
  396. /**
  397. * Bind key events on tabs for accessibility.
  398. */
  399. tabsKeyEvents() {
  400. this.tabsKey = [];
  401. this.listItems.forEach((item, index, array) => {
  402. let tab = null;
  403. if (!item.classList.contains('ecl-tabs__item--hidden')) {
  404. tab = queryOne('.ecl-tabs__link', item);
  405. } else {
  406. const dropdownItem = this.dropdownItems[index];
  407. tab = queryOne('.ecl-tabs__link', dropdownItem);
  408. }
  409. tab.addEventListener('keydown', this.handleKeyboardOnTabs);
  410. this.tabsKey.push(tab);
  411. if (index === 0) {
  412. this.firstTab = tab;
  413. }
  414. if (index === array.length - 1) {
  415. this.lastTab = tab;
  416. }
  417. });
  418. }
  419. /**
  420. * Close the dropdown.
  421. * @param {Event} e
  422. */
  423. closeMoreDropdown(e) {
  424. let el = e.target;
  425. while (el) {
  426. if (el === this.moreButton) {
  427. return;
  428. }
  429. el = el.parentNode;
  430. }
  431. this.moreButton.setAttribute('aria-expanded', false);
  432. this.dropdown.classList.remove('ecl-tabs__dropdown--show');
  433. }
  434. /**
  435. * @param {Event} e
  436. */
  437. handleKeyboardOnTabs(e) {
  438. const tgt = e.currentTarget;
  439. switch (e.key) {
  440. case 'ArrowLeft':
  441. case 'ArrowUp':
  442. this.arrowFocusToTab(tgt, 'prev');
  443. break;
  444. case 'ArrowRight':
  445. case 'ArrowDown':
  446. this.arrowFocusToTab(tgt, 'next');
  447. break;
  448. case 'Home':
  449. this.moveFocus(this.firstTab);
  450. break;
  451. case 'End':
  452. this.moveFocus(this.lastTab);
  453. break;
  454. default:
  455. }
  456. }
  457. /**
  458. * @param {HTMLElement} currentTab tab element
  459. */
  460. moveFocus(currentTab) {
  461. if (currentTab.closest('.ecl-tabs__dropdown')) {
  462. this.moreButton.setAttribute('aria-expanded', true);
  463. this.dropdown.classList.add('ecl-tabs__dropdown--show');
  464. } else {
  465. this.moreButton.setAttribute('aria-expanded', false);
  466. this.dropdown.classList.remove('ecl-tabs__dropdown--show');
  467. }
  468. currentTab.focus();
  469. }
  470. /**
  471. * @param {HTMLElement} currentTab tab element
  472. * @param {string} direction key arrow direction
  473. */
  474. arrowFocusToTab(currentTab, direction) {
  475. let index = this.tabsKey.indexOf(currentTab);
  476. index = direction === 'next' ? index + 1 : index - 1;
  477. const startTab = direction === 'next' ? this.firstTab : this.lastTab;
  478. const endTab = direction === 'next' ? this.lastTab : this.firstTab;
  479. if (this.isMobile) {
  480. if (currentTab !== endTab) {
  481. this.moveFocus(this.tabsKey[index]);
  482. this.shiftTabs(direction);
  483. }
  484. return;
  485. }
  486. if (currentTab === endTab) {
  487. this.moveFocus(startTab);
  488. } else {
  489. this.moveFocus(this.tabsKey[index]);
  490. }
  491. }
  492. }
  493. export default Tabs;