diff options
Diffstat (limited to 'app/assets/javascripts/tabs/index.js')
-rw-r--r-- | app/assets/javascripts/tabs/index.js | 239 |
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()); + } +} |