Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/tabs/index.js')
-rw-r--r--app/assets/javascripts/tabs/index.js239
1 files changed, 239 insertions, 0 deletions
diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js
new file mode 100644
index 00000000000..44937e593e0
--- /dev/null
+++ b/app/assets/javascripts/tabs/index.js
@@ -0,0 +1,239 @@
+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());
+ }
+}