Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revamp tabs & follow ARIA 1.1 practices #33079

Merged
merged 12 commits into from Apr 6, 2022
281 changes: 197 additions & 84 deletions js/src/tab.js
Expand Up @@ -5,7 +5,7 @@
* --------------------------------------------------------------------------
*/

import { defineJQueryPlugin, getElementFromSelector, isDisabled, reflow } from './util/index'
import { defineJQueryPlugin, getElementFromSelector, getNextActiveElement, isDisabled } from './util/index'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import BaseComponent from './base-component'
Expand All @@ -17,158 +17,264 @@ import BaseComponent from './base-component'
const NAME = 'tab'
const DATA_KEY = 'bs.tab'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'

const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`

const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'

const CLASS_NAME_DROPDOWN_MENU = 'dropdown-menu'
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
const CLASS_DROPDOWN = 'dropdown'

const SELECTOR_DROPDOWN = '.dropdown'
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
const SELECTOR_ACTIVE = '.active'
const SELECTOR_ACTIVE_UL = ':scope > li > .active'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]'
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
const SELECTOR_DROPDOWN_ACTIVE_CHILD = ':scope > .dropdown-menu .active'
const SELECTOR_DROPDOWN_MENU = '.dropdown-menu'
const SELECTOR_DROPDOWN_ITEM = '.dropdown-item'
const NOT_SELECTOR_DROPDOWN_TOGGLE = ':not(.dropdown-toggle)'

const SELECTOR_TAB_PANEL = '.list-group, .nav, [role="tablist"]'
const SELECTOR_OUTER = '.nav-item, .list-group-item'
const SELECTOR_INNER = `.nav-link${NOT_SELECTOR_DROPDOWN_TOGGLE}, .list-group-item${NOT_SELECTOR_DROPDOWN_TOGGLE}, [role="tab"]${NOT_SELECTOR_DROPDOWN_TOGGLE}`
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]' // todo:v6: could be only `tab`
const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`

const SELECTOR_DATA_TOGGLE_ACTIVE = `.${CLASS_NAME_ACTIVE}[data-bs-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="list"]`

/**
* Class definition
*/

class Tab extends BaseComponent {
constructor(element) {
super(element)
this._parent = this._element.closest(SELECTOR_TAB_PANEL)

if (!this._parent) {
return
// todo: should Throw exception on v6
// throw new TypeError(`${element.outerHTML} has not a valid parent ${SELECTOR_INNER_ELEM}`)
}

// Set up initial aria attributes
this._setInitialAttributes(this._parent, this._getChildren())

EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
}

// Getters
static get NAME() {
return NAME
}

// Public
show() {
if (this._element.parentNode &&
this._element.parentNode.nodeType === Node.ELEMENT_NODE &&
this._element.classList.contains(CLASS_NAME_ACTIVE)) {
show() { // Shows this elem and deactivate the active sibling if exists
const innerElem = this._element
if (this._elemIsActive(innerElem)) {
return
}

const target = getElementFromSelector(this._element)
const listElement = this._element.closest(SELECTOR_NAV_LIST_GROUP)
let previous
// Search for active tab on same parent to deactivate it
const active = this._getActiveElem()

if (listElement) {
const itemSelector = listElement.nodeName === 'UL' || listElement.nodeName === 'OL' ? SELECTOR_ACTIVE_UL : SELECTOR_ACTIVE
previous = SelectorEngine.find(itemSelector, listElement)
previous = previous[previous.length - 1]
}

const hideEvent = previous ?
EventHandler.trigger(previous, EVENT_HIDE, { relatedTarget: this._element }) :
const hideEvent = active ?
EventHandler.trigger(active, EVENT_HIDE, { relatedTarget: innerElem }) :
null

const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget: previous })
const showEvent = EventHandler.trigger(innerElem, EVENT_SHOW, { relatedTarget: active })

if (showEvent.defaultPrevented || (hideEvent !== null && hideEvent.defaultPrevented)) {
if (showEvent.defaultPrevented || (hideEvent && hideEvent.defaultPrevented)) {
return
}

this._activate(this._element, listElement)
this._deactivate(active, innerElem)
this._activate(innerElem, active)
}

const complete = () => {
EventHandler.trigger(previous, EVENT_HIDDEN, { relatedTarget: this._element })
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget: previous })
// Private
_activate(element, relatedElem) {
if (!element) {
return
}

if (target) {
this._activate(target, target.parentNode, complete)
} else {
complete()
}
}
element.classList.add(CLASS_NAME_ACTIVE)

// Private
_activate(element, container, callback) {
const activeElements = container && (container.nodeName === 'UL' || container.nodeName === 'OL') ?
SelectorEngine.find(SELECTOR_ACTIVE_UL, container) :
SelectorEngine.children(container, SELECTOR_ACTIVE)
this._activate(getElementFromSelector(element)) // Search and activate/show the proper section

const active = activeElements[0]
const isTransitioning = callback && (active && active.classList.contains(CLASS_NAME_FADE))
const isAnimated = element.classList.contains(CLASS_NAME_FADE)
const complete = () => {
if (isAnimated) { // todo: maybe is redundant
element.classList.add(CLASS_NAME_SHOW)
}

const complete = () => this._transitionComplete(element, active, callback)
if (element.getAttribute('role') !== 'tab') {
return
}

if (active && isTransitioning) {
active.classList.remove(CLASS_NAME_SHOW)
this._queueCallback(complete, element, true)
} else {
complete()
element.focus()
element.removeAttribute('tabindex')
element.setAttribute('aria-selected', true)
this._toggleDropDown(element, true)
EventHandler.trigger(element, EVENT_SHOWN, {
relatedTarget: relatedElem
})
}

this._queueCallback(complete, element, isAnimated)
}

_transitionComplete(element, active, callback) {
if (active) {
active.classList.remove(CLASS_NAME_ACTIVE)
_deactivate(element, relatedElem) {
if (!element) {
return
}

element.classList.remove(CLASS_NAME_ACTIVE)
element.blur()

const dropdownChild = SelectorEngine.findOne(SELECTOR_DROPDOWN_ACTIVE_CHILD, active.parentNode)
this._deactivate(getElementFromSelector(element)) // Search and deactivate the shown section too

if (dropdownChild) {
dropdownChild.classList.remove(CLASS_NAME_ACTIVE)
const isAnimated = element.classList.contains(CLASS_NAME_FADE)
const complete = () => {
if (isAnimated) { // todo maybe is redundant
element.classList.remove(CLASS_NAME_SHOW)
}

if (active.getAttribute('role') === 'tab') {
active.setAttribute('aria-selected', false)
if (element.getAttribute('role') !== 'tab') {
return
}

element.setAttribute('aria-selected', false)
element.setAttribute('tabindex', '-1')
this._toggleDropDown(element, false)
EventHandler.trigger(element, EVENT_HIDDEN, { relatedTarget: relatedElem })
}

element.classList.add(CLASS_NAME_ACTIVE)
if (element.getAttribute('role') === 'tab') {
element.setAttribute('aria-selected', true)
this._queueCallback(complete, element, isAnimated)
}

_keydown(event) {
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
return
}
GeoSot marked this conversation as resolved.
Show resolved Hide resolved

event.stopPropagation()// stopPropagation/preventDefault both added to support up/down keys without scrolling the page
event.preventDefault()
const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
const nextActiveElement = getNextActiveElement(this._getChildren(), event.target, isNext, true)
Tab.getOrCreateInstance(nextActiveElement).show()
}

_getChildren() { // collection of inner elements
return SelectorEngine.find(SELECTOR_INNER_ELEM, this._parent)
}

_getActiveElem() {
return this._getChildren().find(child => this._elemIsActive(child)) || null
}

_setInitialAttributes(parent, children) {
this._setAttributeIfNotExists(parent, 'role', 'tablist')

for (const child of children) {
this._setInitialAttributesOnChild(child)
}
}

reflow(element)
_setInitialAttributesOnChild(child) {
child = this._getInnerElement(child)
const isActive = this._elemIsActive(child)
const outerElem = this._getOuterElement(child)
child.setAttribute('aria-selected', isActive)

if (element.classList.contains(CLASS_NAME_FADE)) {
element.classList.add(CLASS_NAME_SHOW)
if (outerElem !== child) {
this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
}

let parent = element.parentNode
if (parent && parent.nodeName === 'LI') {
parent = parent.parentNode
if (!isActive) {
child.setAttribute('tabindex', '-1')
}

if (parent && parent.classList.contains(CLASS_NAME_DROPDOWN_MENU)) {
const dropdownElement = element.closest(SELECTOR_DROPDOWN)
this._setAttributeIfNotExists(child, 'role', 'tab')

if (dropdownElement) {
for (const dropdown of SelectorEngine.find(SELECTOR_DROPDOWN_TOGGLE, dropdownElement)) {
dropdown.classList.add(CLASS_NAME_ACTIVE)
}
}
// set attributes to the related panel too
this._setInitialAttributesOnTargetPanel(child)
}

_setInitialAttributesOnTargetPanel(child) {
const target = getElementFromSelector(child)

element.setAttribute('aria-expanded', true)
if (!target) {
return
}

if (callback) {
callback()
this._setAttributeIfNotExists(target, 'role', 'tabpanel')
GeoSot marked this conversation as resolved.
Show resolved Hide resolved

if (child.id) {
this._setAttributeIfNotExists(target, 'aria-labelledby', `#${child.id}`)
}
}

_toggleDropDown(element, open) {
const outerElem = this._getOuterElement(element)
if (!outerElem.classList.contains(CLASS_DROPDOWN)) {
return
}

const toggle = (selector, className) => {
const element = SelectorEngine.findOne(selector, outerElem)
if (element) {
element.classList.toggle(className, open)
}
}

toggle(SELECTOR_DROPDOWN_TOGGLE, CLASS_NAME_ACTIVE)
toggle(SELECTOR_DROPDOWN_MENU, CLASS_NAME_SHOW)
toggle(SELECTOR_DROPDOWN_ITEM, CLASS_NAME_ACTIVE)
outerElem.setAttribute('aria-expanded', open)
}

_setAttributeIfNotExists(element, attribute, value) {
if (!element.hasAttribute(attribute)) {
element.setAttribute(attribute, value)
}
}

_elemIsActive(elem) {
return elem.classList.contains(CLASS_NAME_ACTIVE)
}

// Try to get the inner element (usually the .nav-link)
_getInnerElement(elem) {
return elem.matches(SELECTOR_INNER_ELEM) ? elem : SelectorEngine.findOne(SELECTOR_INNER_ELEM, elem)
}

// Try to get the outer element (usually the .nav-item)
_getOuterElement(elem) {
return elem.closest(SELECTOR_OUTER) || elem
}

// Static
static jQueryInterface(config) {
return this.each(function () {
const data = Tab.getOrCreateInstance(this)

if (typeof config === 'string') {
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`)
}
if (typeof config !== 'string') {
return
}

data[config]()
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}

data[config]()
})
}
}
Expand All @@ -186,10 +292,17 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
return
}

const data = Tab.getOrCreateInstance(this)
data.show()
Tab.getOrCreateInstance(this).show()
})

/**
* Initialize on focus
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {
Tab.getOrCreateInstance(element)
}
})
/**
* jQuery
*/
Expand Down