gitlab-org--gitlab-foss/app/assets/javascripts/tabs/index.js

257 lines
7.4 KiB
JavaScript

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());
}
}