2022-01-26 12:08:38 +05:30
|
|
|
import { uniqueId } from 'lodash';
|
2022-08-13 15:12:31 +05:30
|
|
|
import { historyReplaceState, NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
|
2022-01-26 12:08:38 +05:30
|
|
|
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,
|
2022-08-13 15:12:31 +05:30
|
|
|
HISTORY_TYPE_HASH,
|
|
|
|
ALLOWED_HISTORY_TYPES,
|
2022-01-26 12:08:38 +05:30
|
|
|
} from './constants';
|
|
|
|
|
2022-08-13 15:12:31 +05:30
|
|
|
export { TAB_SHOWN_EVENT, HISTORY_TYPE_HASH };
|
2022-01-26 12:08:38 +05:30
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*
|
2022-08-13 15:12:31 +05:30
|
|
|
* @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.
|
2022-01-26 12:08:38 +05:30
|
|
|
*/
|
2022-08-13 15:12:31 +05:30
|
|
|
constructor(el, { history = null } = {}) {
|
2022-01-26 12:08:38 +05:30
|
|
|
if (!el) {
|
|
|
|
throw new Error('Cannot instantiate GlTabsBehavior without an element');
|
|
|
|
}
|
|
|
|
|
|
|
|
this.destroyFns = [];
|
|
|
|
this.tabList = el;
|
|
|
|
this.tabs = this.getTabs();
|
|
|
|
this.activeTab = null;
|
|
|
|
|
2022-08-13 15:12:31 +05:30
|
|
|
this.history = ALLOWED_HISTORY_TYPES.includes(history) ? history : null;
|
|
|
|
|
2022-01-26 12:08:38 +05:30
|
|
|
this.setAccessibilityAttrs();
|
|
|
|
this.bindEvents();
|
2022-08-13 15:12:31 +05:30
|
|
|
if (this.history === HISTORY_TYPE_HASH) this.loadInitialTab();
|
2022-01-26 12:08:38 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2022-08-13 15:12:31 +05:30
|
|
|
tabPanel.classList.add(NO_SCROLL_TO_HASH_CLASS);
|
2022-01-26 12:08:38 +05:30
|
|
|
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);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-08-13 15:12:31 +05:30
|
|
|
loadInitialTab() {
|
|
|
|
const tab = this.tabList.querySelector(`a[href="${CSS.escape(window.location.hash)}"]`);
|
|
|
|
this.activateTab(tab || this.activeTab);
|
|
|
|
}
|
|
|
|
|
2022-01-26 12:08:38 +05:30
|
|
|
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);
|
|
|
|
|
2022-08-13 15:12:31 +05:30
|
|
|
if (this.history === HISTORY_TYPE_HASH) historyReplaceState(tabToActivate.getAttribute('href'));
|
2022-01-26 12:08:38 +05:30
|
|
|
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());
|
|
|
|
}
|
|
|
|
}
|