Skip to content

Instantly share code, notes, and snippets.

@iamrobert
Last active February 27, 2023 12:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iamrobert/ae7fd738151e909f351adce0393d05fc to your computer and use it in GitHub Desktop.
Save iamrobert/ae7fd738151e909f351adce0393d05fc to your computer and use it in GitHub Desktop.
/*
* Accessible AccordionTabs, by Matthias Ott (@m_ott)
*
* Based on the work of @stowball (https://codepen.io/stowball/pen/xVWwWe)
*
*/
(function () {
'use strict';
function AccordionTabs(el, options) {
if (!el) {
return;
}
this.el = el;
this.tabTriggers = this.el.getElementsByClassName('js-tabs-trigger');
this.tabPanels = this.el.getElementsByClassName('js-tabs-panel');
this.accordionTriggers = this.el.getElementsByClassName('js-accordion-trigger');
this.options = this._extend({
breakpoint: 640,
tabsAllowed: true,
selectedTab: 0,
startCollapsed: false
}, options);
if (el.getAttribute('data-tabs-allowed') == "true") {
this.options.tabsAllowed = true;
} else if (el.getAttribute('data-tabs-allowed') == "false") {
this.options.tabsAllowed = false;
}
if (el.getAttribute('data-breakpoint')) {
this.options.breakpoint = parseInt(el.getAttribute('data-breakpoint'));
}
if (el.getAttribute('data-selected-tab')) {
this.options.selectedTab = parseInt(el.getAttribute('data-selected-tab'));
}
if (el.getAttribute('data-start-collapsed') == "true") {
this.options.startCollapsed = true;
} else if (el.getAttribute('data-start-collapsed') == "false") {
this.options.startCollapsed = false;
}
if (this.tabTriggers.length === 0 || this.tabTriggers.length !== this.tabPanels.length) {
return;
}
// get Hash From URL
var URLhash = new URL(document.URL).hash;
URLhash = URLhash.replace('#', '');
if (URLhash) {
// get all tabSections
var tabSections = document.querySelectorAll("section.tabs-panel");
// initialize a counter variable
var count = 0;
// iterate through the elements to find the one with the specified URLhash
for (var i = 0; i < tabSections.length; i++) {
var tabSection = tabSections[i];
if (tabSection.id === URLhash) {
// increment the counter if the ID matches
count = i;
// count++;
}
}
// log the final count
this.options.selectedTab = count;
}
this._init();
}
AccordionTabs.prototype._init = function () {
var _this = this;
this.tabTriggersLength = this.tabTriggers.length;
this.accordionTriggersLength = this.accordionTriggers.length;
this.selectedTab = 0;
this.prevSelectedTab = null;
this.clickListener = this._clickEvent.bind(this);
this.keydownListener = this._keydownEvent.bind(this);
this.keys = {
prev: 37,
next: 39,
space: 32,
enter: 13
};
if (window.innerWidth >= this.options.breakpoint && this.options.tabsAllowed) {
this.isAccordion = false;
} else {
this.isAccordion = true;
}
for (var i = 0; i < this.tabTriggersLength; i++) {
this.tabTriggers[i].index = i;
this.tabTriggers[i].addEventListener('click', this.clickListener, false);
this.tabTriggers[i].addEventListener('keydown', this.keydownListener, false);
if (this.tabTriggers[i].classList.contains('is-selected')) {
this.selectedTab = i;
}
this._hide(i);
}
for (var i = 0; i < this.accordionTriggersLength; i++) {
this.accordionTriggers[i].index = i;
this.accordionTriggers[i].addEventListener('click', this.clickListener, false);
this.accordionTriggers[i].addEventListener('keydown', this.keydownListener, false);
if (this.accordionTriggers[i].classList.contains('is-selected')) {
this.selectedTab = i;
}
}
if (!isNaN(this.options.selectedTab)) {
this.selectedTab = this.options.selectedTab < this.tabTriggersLength ? this.options.selectedTab : this.tabTriggersLength - 1;
}
this.el.classList.add('is-initialized');
if (this.options.tabsAllowed) {
this.el.classList.add('tabs-allowed');
}
// If the accordion should not start collapsed, open the first element
if (!this.options.startCollapsed || !this.isAccordion) {
this.selectTab(this.selectedTab, false);
}
var resizeTabs = this._debounce(function () {
// This gets delayed for performance reasons
if (window.innerWidth >= _this.options.breakpoint && _this.options.tabsAllowed) {
_this.isAccordion = false;
if (_this.options.tabsAllowed) {
_this.el.classList.add('tabs-allowed');
}
_this.selectTab(_this.selectedTab);
} else {
_this.isAccordion = true;
_this.el.classList.remove('tabs-allowed');
if (!_this.options.startCollapsed) {
_this.selectTab(_this.selectedTab);
}
}
}, 50);
window.addEventListener('resize', resizeTabs);
};
AccordionTabs.prototype._clickEvent = function (e) {
e.preventDefault();
var closestTrigger = this._getClosest(e.target, '.js-tabs-trigger');
var closestTab = 0;
if (closestTrigger == null) {
closestTrigger = this._getClosest(e.target, '.js-accordion-trigger');
closestTab = this._getClosest(closestTrigger, '.js-tabs-panel');
this.isAccordion = true;
} else {
this.isAccordion = false;
}
var targetIndex = e.target.index != null ? e.target.index : closestTab.index;
if (targetIndex === this.selectedTab && !this.isAccordion) {
return;
}
this.selectTab(targetIndex, true);
//ADD HASH TO URL
if (!this.isAccordion) {
var getHash = new URL(e.target);
history.replaceState(null, '', getHash.hash);
}
if (this.isAccordion) {
history.replaceState(null, '', '#' + closestTrigger.getAttribute('aria-controls'));
}
};
AccordionTabs.prototype._keydownEvent = function (e) {
var targetIndex;
if (e.keyCode === this.keys.prev || e.keyCode === this.keys.next || e.keyCode === this.keys.space || e.keyCode === this.keys.enter) {
e.preventDefault();
}
else {
return;
}
if (e.keyCode === this.keys.prev && e.target.index > 0 && !this.isAccordion) {
targetIndex = e.target.index - 1;
}
else if (e.keyCode === this.keys.next && e.target.index < this.tabTriggersLength - 1 && !this.isAccordion) {
targetIndex = e.target.index + 1;
}
else if (e.keyCode === this.keys.space || e.keyCode === this.keys.enter) {
targetIndex = e.target.index;
}
else {
return;
}
this.selectTab(targetIndex, true);
};
AccordionTabs.prototype._show = function (index, userInvoked) {
this.tabPanels[index].removeAttribute('tabindex');
this.tabTriggers[index].removeAttribute('tabindex');
this.tabTriggers[index].classList.add('is-selected');
this.tabTriggers[index].setAttribute('aria-selected', true);
this.accordionTriggers[index].setAttribute('aria-expanded', true);
var panelContent = this.tabPanels[index].getElementsByClassName("content")[0];
panelContent.setAttribute('aria-hidden', false);
panelContent.classList.remove('is-hidden');
panelContent.classList.add('is-open');
this.tabPanels[index].classList.remove('is-hidden');
this.tabPanels[index].classList.add('is-open');
if (userInvoked) {
this.tabTriggers[index].focus();
}
};
AccordionTabs.prototype._hide = function (index) {
this.tabTriggers[index].classList.remove('is-selected');
this.tabTriggers[index].setAttribute('aria-selected', false);
this.tabTriggers[index].setAttribute('tabindex', -1);
this.accordionTriggers[index].setAttribute('aria-expanded', false);
var panelContent = this.tabPanels[index].getElementsByClassName("content")[0];
panelContent.setAttribute('aria-hidden', true);
panelContent.classList.remove('is-open');
panelContent.classList.add('is-hidden');
this.tabPanels[index].classList.remove('is-open');
this.tabPanels[index].classList.add('is-hidden');
this.tabPanels[index].setAttribute('tabindex', -1);
};
AccordionTabs.prototype.selectTab = function (index, userInvoked) {
if (index === null) {
if (this.isAccordion) {
return;
} else {
index = 0;
}
}
if (!this.tabPanels[index].classList.contains('is-hidden') && userInvoked) {
if (index === this.selectedTab) {
this.selectedTab = null;
} else {
this.selectedTab = null;
this.prevSelectedTab = index;
}
this._hide(index);
return;
}
if (this.isAccordion) {
this.prevSelectedTab = this.selectedTab;
this.selectedTab = index;
} else {
if (this.prevSelectedTab === null || !this.isAccordion) {
for (var i = 0; i < this.tabTriggersLength; i++) {
if (i !== index) {
this._hide(i);
}
}
}
else {
this._hide(this.selectedTab);
}
this.prevSelectedTab = this.selectedTab;
this.selectedTab = index;
}
this._show(this.selectedTab, userInvoked);
};
AccordionTabs.prototype.destroy = function () {
for (var i = 0; i < this.tabTriggersLength; i++) {
this.tabTriggers[i].classList.remove('is-selected');
this.tabTriggers[i].removeAttribute('aria-selected');
this.tabTriggers[i].removeAttribute('tabindex');
this.tabPanels[i].classList.remove('is-hidden');
this.tabPanels[i].removeAttribute('aria-hidden');
this.tabPanels[i].removeAttribute('tabindex');
this.tabTriggers[i].removeEventListener('click', this.clickListener, false);
this.tabTriggers[i].removeEventListener('keydown', this.keydownListener, false);
delete this.tabTriggers[i].index;
}
this.el.classList.remove('is-initialized');
};
/**
* Get the closest matching element up the DOM tree.
* @private
* @param {Element} elem Starting element
* @param {String} selector Selector to match against
* @return {Boolean|Element} Returns null if not match found
*/
AccordionTabs.prototype._getClosest = function (elem, selector) {
// Element.matches() polyfill
if (!Element.prototype.matches) {
Element.prototype.matches =
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector ||
function (s) {
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
i = matches.length;
while (--i >= 0 && matches.item(i) !== this) { }
return i > -1;
};
}
// Get closest match
for (; elem && elem !== document; elem = elem.parentNode) {
if (elem.matches(selector)) return elem;
}
return null;
};
// Pass in the objects to merge as arguments.
// For a deep extend, set the first argument to `true`.
AccordionTabs.prototype._extend = function () {
// Variables
var extended = {};
var deep = false;
var i = 0;
var length = arguments.length;
// Check if a deep merge
if (Object.prototype.toString.call(arguments[0]) === '[object Boolean]') {
deep = arguments[0];
i++;
}
// Merge the object into the extended object
var merge = function (obj) {
for (var prop in obj) {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
// If deep merge and property is an object, merge properties
if (deep && Object.prototype.toString.call(obj[prop]) === '[object Object]') {
extended[prop] = extend(true, extended[prop], obj[prop]);
} else {
extended[prop] = obj[prop];
}
}
}
};
// Loop through each object and conduct a merge
for (; i < length; i++) {
var obj = arguments[i];
merge(obj);
}
return extended;
};
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
AccordionTabs.prototype._debounce = function (func, wait, immediate) {
var timeout;
return function () {
var context = this, args = arguments;
var later = function () {
timeout = null;
if (!immediate) { func.apply(context, args); };
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) { func.apply(context, args) };
};
};
var slice = Array.prototype.slice;
function $(expr, con) {
return typeof expr === "string" ? (con || document).querySelector(expr) : expr || null;
}
function $$(expr, con) {
return slice.call((con || document).querySelectorAll(expr));
}
// Initialization
function init() {
$$(".js-tabs").forEach(function (input) {
new AccordionTabs(input);
});
}
// Are we in a browser? Check for Document constructor
if (typeof Document !== "undefined") {
// DOM already loaded?
if (document.readyState !== "loading") {
init();
}
else {
// Wait for it
document.addEventListener("DOMContentLoaded", init);
}
}
// Export on self when in a browser
if (typeof self !== "undefined") {
self.AccordionTabs = AccordionTabs;
}
// Expose as a CJS module
if (typeof module === "object" && module.exports) {
module.exports = AccordionTabs;
}
return AccordionTabs;
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment