Skip to content

Instantly share code, notes, and snippets.

@firestar300
Last active March 4, 2021 10:49
Show Gist options
  • Save firestar300/c31faf9478b69b9768701783f385b763 to your computer and use it in GitHub Desktop.
Save firestar300/c31faf9478b69b9768701783f385b763 to your computer and use it in GitHub Desktop.
Accessible Tabs
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
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