import { uniqueId } from 'lodash'; import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils'; import { ACTIVE_TAB_CLASSES, ATTR_ROLE, ATTR_ARIA_CONTROLS, ATTR_TABINDEX, ATTR_ARIA_SELECTED, ATTR_ARIA_LABELLEDBY, ACTIVE_PANEL_CLASS, KEY_CODE_LEFT, KEY_CODE_UP, KEY_CODE_RIGHT, KEY_CODE_DOWN, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH, ALLOWED_HISTORY_TYPES, } from './constants'; export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH }; /** * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and * `gl_tab_link_to` Rails helpers. * * Example using `href` references: * * ```haml * = gl_tabs_nav({ class: 'js-my-tabs' }) do * = gl_tab_link_to '#foo', item_active: true do * = _('Foo') * = gl_tab_link_to '#bar' do * = _('Bar') * * .tab-content * .tab-pane.active#foo * .tab-pane#bar * ``` * * ```javascript * import { GlTabsBehavior } from '~/tabs'; * * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); * ``` * * Example using `aria-controls` references: * * ```haml * = gl_tabs_nav({ class: 'js-my-tabs' }) do * = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do * = _('Foo') * = gl_tab_link_to '#', 'aria-controls': 'bar' do * = _('Bar') * * .tab-content * .tab-pane.active#foo * .tab-pane#bar * ``` * * ```javascript * import { GlTabsBehavior } from '~/tabs'; * * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); * ``` * * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot * easily be rewritten in Vue. * * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not * work correctly. * * Tab panels must exist somewhere in the page for the tabs to control. Tab panels * must: * - be immediate children of a `.tab-content` element * - have the `tab-pane` class * - if the panel is active, have the `active` class * - have a unique `id` attribute * * In order to associate tabs with panels, the tabs must reference their panel's * `id` by having one of the following attributes: * - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value) * - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`) * * Exactly one tab/panel must be active in the original markup. * * Call the `destroy` method on an instance to remove event listeners that were * added during construction. Other DOM mutations (like ARIA attributes) are * _not_ reverted. */ export class GlTabsBehavior { /** * Create a GlTabsBehavior instance. * * @param {HTMLElement} el - The element created by the Rails `gl_tabs_nav` helper. * @param {Object} [options] * @param {'hash' | null} [options.history=null] - Sets the type of routing GlTabs will use when navigating between tabs. * 'hash': Updates the URL hash with the current tab ID. * null: No routing mechanism will be used. */ constructor(el, { history = null } = {}) { if (!el) { throw new Error('Cannot instantiate GlTabsBehavior without an element'); } this.destroyFns = []; this.tabList = el; this.tabs = this.getTabs(); this.activeTab = null; this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null; this.setAccessibilityAttrs(); this.bindEvents(); if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab(); } setAccessibilityAttrs() { this.tabList.setAttribute(ATTR_ROLE, 'tablist'); this.tabs.forEach((tab) => { if (!tab.hasAttribute('id')) { tab.setAttribute('id', uniqueId('gl_tab_nav__tab_')); } if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) { this.activeTab = tab; tab.setAttribute(ATTR_ARIA_SELECTED, 'true'); tab.removeAttribute(ATTR_TABINDEX); } else { tab.setAttribute(ATTR_ARIA_SELECTED, 'false'); tab.setAttribute(ATTR_TABINDEX, '-1'); } tab.setAttribute(ATTR_ROLE, 'tab'); tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation'); const tabPanel = this.getPanelForTab(tab); if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) { tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id); } tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS); tabPanel.setAttribute(ATTR_ROLE, 'tabpanel'); tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id); }); } bindEvents() { this.tabs.forEach((tab) => { this.bindEvent(tab, 'click', (event) => { event.preventDefault(); if (tab !== this.activeTab) { this.activateTab(tab); } }); this.bindEvent(tab, 'keydown', (event) => { const { code } = event; if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) { event.preventDefault(); this.activatePreviousTab(); } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) { event.preventDefault(); this.activateNextTab(); } }); }); } bindEvent(el, ...args) { el.addEventListener(...args); this.destroyFns.push(() => { el.removeEventListener(...args); }); } loadInitialTab() { const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`); this.activateTab(tab || this.activeTab); } activatePreviousTab() { const currentTabIndex = this.tabs.indexOf(this.activeTab); if (currentTabIndex <= 0) return; const previousTab = this.tabs[currentTabIndex - 1]; this.activateTab(previousTab); previousTab.focus(); } activateNextTab() { const currentTabIndex = this.tabs.indexOf(this.activeTab); if (currentTabIndex >= this.tabs.length - 1) return; const nextTab = this.tabs[currentTabIndex + 1]; this.activateTab(nextTab); nextTab.focus(); } getTabs() { return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item')); } // eslint-disable-next-line class-methods-use-this getPanelForTab(tab) { const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS); if (ariaControls) { return document.querySelector(`#${ariaControls}`); } return document.querySelector(tab.getAttribute('href')); } activateTab(tabToActivate) { // Deactivate active tab first this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false'); this.activeTab.setAttribute(ATTR_TABINDEX, '-1'); this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES); const activePanel = this.getPanelForTab(this.activeTab); activePanel.classList.remove(ACTIVE_PANEL_CLASS); // Now activate the given tab/panel tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true'); tabToActivate.removeAttribute(ATTR_TABINDEX); tabToActivate.classList.add(...ACTIVE_TAB_CLASSES); const tabPanel = this.getPanelForTab(tabToActivate); tabPanel.classList.add(ACTIVE_PANEL_CLASS); if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href')); this.activeTab = tabToActivate; this.dispatchTabShown(tabToActivate, tabPanel); } // eslint-disable-next-line class-methods-use-this dispatchTabShown(tab, activeTabPanel) { const event = new CustomEvent(TAB_SHOWN_EVENT, { bubbles: true, detail: { activeTabPanel, }, }); tab.dispatchEvent(event); } destroy() { this.destroyFns.forEach((destroy) => destroy()); } }