UNPKG

a11y-tabs

Version:

A lightweight JavaScript package to facilitate a11y-compliant tabbed interfaces

209 lines (193 loc) 7.44 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.A11yTabs = factory()); })(this, (function () { 'use strict'; /** * Tabs — a lightweight JavaScript package to facilitate a11y-compliant tabbed interfaces */ class Tabs { tabs = []; tabItems = null; panels = null; currentIndex = -1; isVerticalOrientation = false; /** * * @param {string} tabListSelector A CSS selector to pointing to your tablist — the parent element to your tab buttons * which must have attribute `[role="tablist"]` as we will combine this selector to find all its child tab buttons with * `role="tab"`. So your tab buttons must all have the `[role="tab"]` attributes. These should also contain `aria-controls` * attribute pointing to the ID of corresponding tab-panel. For example: * `<div role="tablist"><button class="tab-item tab-button" role="tab" aria-controls="panel-1">Tab 1</button>...and so on` * @param {string} panelsSelector A CSS selector to find by `querySelectorAll`, your panel items. This selector * must be able to find all panels with role="tabpanel" items that correspond to your tab button's `aria-controls` IDs: * ```js * <div id="panel-1" role="tabpanel" tab-title="Tab 1"> * <div>Tab 1 content.</div> * </div> * ``` * @param {number} activeIndex The index of the initial tab you'd like opened. Defaults to 0. */ constructor(tabListSelector, panelsSelector, activeIndex) { const initialActiveIndex = activeIndex || 0; this.tabItems = document.querySelectorAll(`${tabListSelector} [role="tab"]`); this.panels = document.querySelectorAll(panelsSelector); const tablist = document.querySelector(tabListSelector); const orientation = tablist.getAttribute('aria-orientation'); this.isVerticalOrientation = orientation && orientation === 'vertical' ? true : false; this.currentIndex = initialActiveIndex; this.selectTab = this.selectTab.bind(this); this.deselectTabs = this.deselectTabs.bind(this); this.resetPanels = this.resetPanels.bind(this); this.handleClick = this.handleClick.bind(this); this.handleKeydown = this.handleKeydown.bind(this); this.initTabs(); } /** * Only ran once from our contructor this sets up the tabs, panels, and adds click and keydown listeners */ initTabs() { this.resetPanels(); this.deselectTabs(); for (let i = 0; i < this.tabItems.length; i++) { const tab = this.tabItems[i]; this.tabs[i] = tab; this.tabs[i].index = i; tab.addEventListener('click', this.handleClick); tab.addEventListener('keydown', this.handleKeydown); if (i === this.currentIndex) { this._selectTab(tab); } } } /** * Activates a panel by removing it's hidden attribute and setting aria-expanded to true * @param {string} panelId a tab panel's ID obtained from the `aria-controls` attribute */ activatePanel(panelId) { document.querySelector(`#${panelId}`).removeAttribute('hidden'); document.querySelector(`#${panelId}`).setAttribute('aria-expanded', true); } /** * Used to remove .active class from all tabs, set to tabindex -1, and aria-selected false. */ deselectTabs() { for (let i = 0; i < this.tabItems.length; i++) { const tab = this.tabItems[i]; tab.classList.remove('active'); tab.setAttribute('tabindex', '-1'); tab.setAttribute('aria-selected', false); } } /** * Essentially, will set all tab panels to hidden, aria-expanded false, and tabindex 0 (so they can be tabbed into) */ resetPanels() { for (let j = 0; j < this.panels.length; j++) { const panel = this.panels[j]; panel.setAttribute('hidden', true); panel.setAttribute('aria-expanded', false); panel.setAttribute('tabindex', '0'); } } /** * Sets the selected tab and delegates to `activatePanel` to, ultimately, select a new * tab / tab panel pair that is selected aka expanded. * * @param {HTMLElement} tabElement the tab button element to select */ selectTab(tabElement) { this.deselectTabs(); this.resetPanels(); this._selectTab(tabElement); } _selectTab(tabElement) { tabElement.classList.add('active'); tabElement.setAttribute('aria-selected', 'true'); tabElement.removeAttribute('tabindex'); this.activatePanel(tabElement.getAttribute('aria-controls')); } /** * * @param {Event} ev fired when user clicks or presses <space | enter> on a tab button */ handleClick(ev) { this.selectTab(ev.target); } decrementIndex() { this.currentIndex--; if (this.currentIndex < 0) { this.currentIndex = this.tabItems.length - 1; } } incrementIndex() { this.currentIndex++; if (this.currentIndex >= this.tabItems.length) { this.currentIndex = 0; } } /** * This is used to implement our a11y keyboard navigation. * * @param {Event} ev fires when the user presses a key * @returns */ handleKeydown(ev) { switch (ev.key) { case 'Up': // IE Edge case 'ArrowUp': // If orientation vertical update current index else "fall-through" so default return if (this.isVerticalOrientation) { this.decrementIndex(); break; } case 'Down': // IE Edge case 'ArrowDown': // If orientation vertical update current index else "fall-through" so default return if (this.isVerticalOrientation) { this.incrementIndex(); break; } case 'Left': // IE Edge case 'ArrowLeft': // If orientation horizontal update current index else "fall-through" so default return if (!this.isVerticalOrientation) { this.decrementIndex(); break; } case 'Right': // IE Edge case 'ArrowRight': // If orientation horizontal update current index else "fall-through" so default return if (!this.isVerticalOrientation) { this.incrementIndex(); break; } case 'Home': case 'ArrowHome': this.currentIndex = 0; break; case 'End': case 'ArrowEnd': this.currentIndex = this.tabItems.length - 1; break; case 'Enter': case 'Space': this.selectTab(ev.target); ev.target.focus(); break; default: // If an unaccounted for key is pressed returning here prevents the // `preventDefault` below (thus preserving normal behavior) return; } ev.preventDefault(); /** * Ensure we have proper focus on currently selected tab. We do * NOT want to set its tabindex yet though; that only happens when * we click, enter, or space on a tab button. */ this.tabs[this.currentIndex].focus(); } } return Tabs; }));