Last active
March 4, 2021 10:49
-
-
Save firestar300/c31faf9478b69b9768701783f385b763 to your computer and use it in GitHub Desktop.
Accessible Tabs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const $ = jQuery | |
class AbstractDomElement { | |
constructor(element, options) { | |
let oldInstance | |
// provide an explicit spaceName to prevent conflict after minification | |
// MaClass.nameSpace = 'MaClass' | |
this.constructor.nameSpace = this.constructor.nameSpace || this.constructor.name | |
const nameSpace = this.constructor.nameSpace | |
// if no spacename beapi, create it - avoid futur test | |
if (!element.beapi) { | |
element.beapi = {} | |
} | |
oldInstance = element.beapi[nameSpace] | |
if (oldInstance) { | |
console.warn( | |
'[AbstractDomElement] more than 1 class is initialised with the same name space on :', | |
element, | |
oldInstance | |
) | |
oldInstance._isNewInstance = false | |
return oldInstance | |
} | |
this._element = element | |
this._settings = $.extend(true, {}, this.constructor.defaults, options) | |
this._element.beapi[nameSpace] = this | |
this._isNewInstance = true | |
} | |
isNewInstance() { | |
return this._isNewInstance | |
} | |
destroy() { | |
this._element.beapi[this.constructor.nameSpace] = undefined | |
return this | |
} | |
static init(element, options) { | |
foreach(element, (el) => { | |
new this(el, options) | |
}) | |
return this | |
} | |
static hasInstance(element) { | |
const el = getDomElement(element) | |
return el && el.beapi && !!el.beapi[this.nameSpace] | |
} | |
static getInstance(element) { | |
const el = getDomElement(element) | |
return el && el.beapi ? el.beapi[this.nameSpace] : undefined | |
} | |
static destroy(element) { | |
this.foreach(element, (el) => { | |
if (el.beapi && el.beapi[this.nameSpace]) { | |
el.beapi[this.nameSpace].destroy() | |
} | |
}) | |
return this | |
} | |
static foreach(element, callback) { | |
foreach(element, (el) => { | |
if (el.beapi && el.beapi[this.nameSpace]) { | |
callback(el) | |
} | |
}) | |
return this | |
} | |
static initFromPreset() { | |
const preset = this.preset | |
let selector | |
for (selector in preset) { | |
this.init(selector, preset[selector]) | |
} | |
return this | |
} | |
static destroyFromPreset() { | |
const preset = this.preset | |
let selector | |
for (selector in preset) { | |
this.destroy(selector) | |
} | |
return this | |
} | |
} | |
// ---- | |
// utils | |
// ---- | |
function foreach(element, callback) { | |
const el = getDomElements(element) | |
let i | |
for (i = 0; i < el.length; i++) { | |
if (callback(el[i]) === false) break | |
} | |
} | |
function getDomElements(element) { | |
return typeof element === 'string' ? document.querySelectorAll(element) : element.length >= 0 ? element : [element] | |
} | |
function getDomElement(element) { | |
return getDomElements(element)[0] | |
} | |
// ---- | |
// export | |
// ---- | |
export default AbstractDomElement |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AbstractDomElement from './AbstractDomElement' | |
/** | |
* Tabs Class | |
* @author Milan Ricoul | |
*/ | |
class Tabs extends AbstractDomElement { | |
constructor(element, options) { | |
var instance = super(element, options) | |
// avoid double init : | |
if (!instance.isNewInstance()) { | |
return instance | |
} | |
this.handleButtonClick = this.handleButtonClick.bind(this) | |
this.handleKeydown = this.handleKeydown.bind(this) | |
this.focusPreviousTab = this.focusPreviousTab.bind(this) | |
this.focusNextTab = this.focusNextTab.bind(this) | |
this.close = this.close.bind(this) | |
this.init() | |
} | |
/** | |
* Initialization | |
* @author Milan Ricoul | |
*/ | |
init() { | |
this.applyToButtons((button) => button.addEventListener('click', this.handleButtonClick)) | |
document.addEventListener('keydown', this.handleKeydown) | |
} | |
/** | |
* Destroy method | |
* @author Milan Ricoul | |
*/ | |
destroy() { | |
this.applyToButtons((button) => button.removeEventListener('click', this.handleButtonClick)) | |
document.removeEventListener('keydown', this.handleKeydown) | |
} | |
/** | |
* Unselect all tab buttons | |
* @author Milan Ricoul | |
*/ | |
unselectAllButtons() { | |
this.applyToButtons((button) => button.setAttribute('aria-selected', 'false')) | |
} | |
/** | |
* Execute a function for every tab buttons | |
* @param {Function} func callback | |
* @author Milan Ricoul | |
*/ | |
applyToButtons(func) { | |
const el = this._element | |
const s = this._settings | |
const buttons = el.querySelectorAll(s.tabListSelector) | |
Array.prototype.slice.call(buttons).forEach((button) => func(button)) | |
} | |
/** | |
* Open tab panel | |
* @param {HTMLElement} button clicked button | |
* @author Milan Ricoul | |
*/ | |
open(button) { | |
this.applyToButtons((btn) => this.close(btn)) | |
const panel = document.getElementById(button.getAttribute('aria-controls')) | |
button.focus() | |
button.setAttribute('aria-selected', 'true') | |
button.removeAttribute('tabindex') | |
panel.removeAttribute('hidden') | |
} | |
/** | |
* Open tab panel | |
* @param {HTMLElement} button clicked button | |
* @author Milan Ricoul | |
*/ | |
close(button) { | |
const panel = document.getElementById(button.getAttribute('aria-controls')) | |
button.setAttribute('aria-selected', 'false') | |
button.setAttribute('tabindex', '-1') | |
panel.setAttribute('hidden', '') | |
} | |
/** | |
* Focus the previous tab. If not previous tag, focus the last tab. | |
* @author Milan Ricoul | |
*/ | |
focusPreviousTab() { | |
const activeElement = document.activeElement | |
if (activeElement && activeElement.parentNode.getAttribute('role') === 'tablist') { | |
const previousButton = activeElement.previousElementSibling || activeElement.parentNode.lastElementChild | |
this._settings.auto ? this.open(previousButton) : previousButton.focus() | |
} | |
} | |
/** | |
* Focus the next tab. If not next tab, focus the fist tab. | |
* @author Milan Ricoul | |
*/ | |
focusNextTab() { | |
const activeElement = document.activeElement | |
if (activeElement && activeElement.parentNode.getAttribute('role') === 'tablist') { | |
const nextButton = activeElement.nextElementSibling || activeElement.parentNode.firstElementChild | |
this._settings.auto ? this.open(nextButton) : nextButton.focus() | |
} | |
} | |
/** | |
* Handle tab button click | |
* @param {MouseEvent} e Mouse click event | |
* @author Milan Ricoul | |
*/ | |
handleButtonClick(e) { | |
const clickedButton = e.currentTarget | |
const isSelected = clickedButton.getAttribute('aria-selected') === 'true' | |
if (!isSelected) { | |
this.open(clickedButton) | |
} | |
} | |
/** | |
* Handle keyboard keydown | |
* @param {KeyboardEvent} e Keyboard keydown event | |
* @author Milan Ricoul | |
*/ | |
handleKeydown(e) { | |
switch (e.code) { | |
case 'ArrowLeft': | |
this.focusPreviousTab() | |
break | |
case 'ArrowRight': | |
this.focusNextTab() | |
break | |
} | |
} | |
} | |
Tabs.defaults = { | |
auto: false, | |
tabListSelector: 'button[role="tab"]', | |
tabPanelSelector: 'div[role="tabpanel"]', | |
} | |
Tabs.preset = { | |
'.tabs': { | |
auto: true, | |
}, | |
} | |
// loop on each preset | |
export default Tabs |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment