240 lines
6.6 KiB
JavaScript
240 lines
6.6 KiB
JavaScript
|
import { uniqueId } from 'lodash';
|
||
|
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,
|
||
|
} from './constants';
|
||
|
|
||
|
export { TAB_SHOWN_EVENT };
|
||
|
|
||
|
/**
|
||
|
* 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.
|
||
|
*/
|
||
|
constructor(el) {
|
||
|
if (!el) {
|
||
|
throw new Error('Cannot instantiate GlTabsBehavior without an element');
|
||
|
}
|
||
|
|
||
|
this.destroyFns = [];
|
||
|
this.tabList = el;
|
||
|
this.tabs = this.getTabs();
|
||
|
this.activeTab = null;
|
||
|
|
||
|
this.setAccessibilityAttrs();
|
||
|
this.bindEvents();
|
||
|
}
|
||
|
|
||
|
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.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);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
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);
|
||
|
|
||
|
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());
|
||
|
}
|
||
|
}
|