1
0
Fork 0
mirror of https://github.com/twbs/bootstrap.git synced 2022-11-09 12:25:43 -05:00

Revamp tabs & follow ARIA 1.1 practices (#33079)

* Tab: Revamp tab.js & add support Aria features

* Tab: Add tab support, just to keep backwards compatibility. Better to remove it on v6

* Revert "Tab: Add tab support, just to keep backwards compatibility. Better to remove it on v6"

* Support arrow down/up functionality

* add prevent default to avoid scrolling the page during up/down keys handling

* remove panel tabindex handling

* Expand documentation text for JS plugin

* Rearrange new docs to specifically call out a11y

* properly place section

Co-authored-by: XhmikosR <xhmikosr@gmail.com>
Co-authored-by: Patrick H. Lauke <redux@splintered.co.uk>
Co-authored-by: Mark Otto <markdotto@gmail.com>
This commit is contained in:
GeoSot 2022-04-07 02:36:08 +03:00 committed by GitHub
parent 238afd94b3
commit 135b9cdff2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 467 additions and 178 deletions

View file

@ -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'
@ -17,144 +17,248 @@ 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)
const complete = () => {
EventHandler.trigger(previous, EVENT_HIDDEN, { relatedTarget: this._element })
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget: previous })
}
if (target) {
this._activate(target, target.parentNode, complete)
} else {
complete()
}
this._deactivate(active, innerElem)
this._activate(innerElem, 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)
const active = activeElements[0]
const isTransitioning = callback && (active && active.classList.contains(CLASS_NAME_FADE))
const complete = () => this._transitionComplete(element, active, callback)
if (active && isTransitioning) {
active.classList.remove(CLASS_NAME_SHOW)
this._queueCallback(complete, element, true)
} else {
complete()
}
}
_transitionComplete(element, active, callback) {
if (active) {
active.classList.remove(CLASS_NAME_ACTIVE)
const dropdownChild = SelectorEngine.findOne(SELECTOR_DROPDOWN_ACTIVE_CHILD, active.parentNode)
if (dropdownChild) {
dropdownChild.classList.remove(CLASS_NAME_ACTIVE)
}
if (active.getAttribute('role') === 'tab') {
active.setAttribute('aria-selected', false)
}
_activate(element, relatedElem) {
if (!element) {
return
}
element.classList.add(CLASS_NAME_ACTIVE)
if (element.getAttribute('role') === 'tab') {
element.setAttribute('aria-selected', true)
}
reflow(element)
this._activate(getElementFromSelector(element)) // Search and activate/show the proper section
if (element.classList.contains(CLASS_NAME_FADE)) {
element.classList.add(CLASS_NAME_SHOW)
}
let parent = element.parentNode
if (parent && parent.nodeName === 'LI') {
parent = parent.parentNode
}
if (parent && parent.classList.contains(CLASS_NAME_DROPDOWN_MENU)) {
const dropdownElement = element.closest(SELECTOR_DROPDOWN)
if (dropdownElement) {
for (const dropdown of SelectorEngine.find(SELECTOR_DROPDOWN_TOGGLE, dropdownElement)) {
dropdown.classList.add(CLASS_NAME_ACTIVE)
}
const isAnimated = element.classList.contains(CLASS_NAME_FADE)
const complete = () => {
if (isAnimated) { // todo: maybe is redundant
element.classList.add(CLASS_NAME_SHOW)
}
element.setAttribute('aria-expanded', true)
if (element.getAttribute('role') !== 'tab') {
return
}
element.focus()
element.removeAttribute('tabindex')
element.setAttribute('aria-selected', true)
this._toggleDropDown(element, true)
EventHandler.trigger(element, EVENT_SHOWN, {
relatedTarget: relatedElem
})
}
if (callback) {
callback()
this._queueCallback(complete, element, isAnimated)
}
_deactivate(element, relatedElem) {
if (!element) {
return
}
element.classList.remove(CLASS_NAME_ACTIVE)
element.blur()
this._deactivate(getElementFromSelector(element)) // Search and deactivate the shown section too
const isAnimated = element.classList.contains(CLASS_NAME_FADE)
const complete = () => {
if (isAnimated) { // todo maybe is redundant
element.classList.remove(CLASS_NAME_SHOW)
}
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 })
}
this._queueCallback(complete, element, isAnimated)
}
_keydown(event) {
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
return
}
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)
}
}
_setInitialAttributesOnChild(child) {
child = this._getInnerElement(child)
const isActive = this._elemIsActive(child)
const outerElem = this._getOuterElement(child)
child.setAttribute('aria-selected', isActive)
if (outerElem !== child) {
this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
}
if (!isActive) {
child.setAttribute('tabindex', '-1')
}
this._setAttributeIfNotExists(child, 'role', 'tab')
// set attributes to the related panel too
this._setInitialAttributesOnTargetPanel(child)
}
_setInitialAttributesOnTargetPanel(child) {
const target = getElementFromSelector(child)
if (!target) {
return
}
this._setAttributeIfNotExists(target, 'role', 'tabpanel')
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
@ -162,13 +266,15 @@ class Tab extends BaseComponent {
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}"`)
}
data[config]()
if (typeof config !== 'string') {
return
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
})
}
}
@ -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
*/

View file

@ -1,5 +1,5 @@
import Tab from '../../src/tab'
import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
describe('Tab', () => {
let fixtureEl
@ -38,6 +38,19 @@ describe('Tab', () => {
})
})
describe('constructor', () => {
it('Do not Throw exception if not parent', () => {
fixtureEl.innerHTML = [
fixtureEl.innerHTML = '<div class=""><div class="nav-link"></div></div>'
].join('')
const navEl = fixtureEl.querySelector('.nav-link')
expect(() => {
new Tab(navEl) // eslint-disable-line no-new
}).not.toThrowError(TypeError)
})
})
describe('show', () => {
it('should activate element by tab id (using buttons, the preferred semantic way)', () => {
return new Promise(resolve => {
@ -168,9 +181,9 @@ describe('Tab', () => {
it('should not fire shown when show is prevented', () => {
return new Promise((resolve, reject) => {
fixtureEl.innerHTML = '<div class="nav"></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const navEl = fixtureEl.querySelector('div')
const navEl = fixtureEl.querySelector('.nav > div')
const tab = new Tab(navEl)
const expectDone = () => {
setTimeout(() => {
@ -256,7 +269,7 @@ describe('Tab', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" data-bs-target="#profile">Profile</button></li>',
' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>'
].join('')
@ -374,10 +387,10 @@ describe('Tab', () => {
describe('dispose', () => {
it('should dispose a tab', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const el = fixtureEl.querySelector('div')
const tab = new Tab(fixtureEl.querySelector('div'))
const el = fixtureEl.querySelector('.nav > div')
const tab = new Tab(fixtureEl.querySelector('.nav > div'))
expect(Tab.getInstance(el)).not.toBeNull()
@ -387,11 +400,161 @@ describe('Tab', () => {
})
})
describe('_activate', () => {
it('should not be called if element argument is null', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li class="nav-link"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')
const tab = new Tab(tabEl)
const spy = jasmine.createSpy('spy')
spyOn(tab, '_queueCallback')
tab._activate(null, spy)
expect(tab._queueCallback).not.toHaveBeenCalled()
expect(spy).not.toHaveBeenCalled()
})
})
describe('_setInitialAttributes', () => {
it('should put aria attributes', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" id="foo" data-bs-target="#panel"></li>',
' <li class="nav-link" data-bs-target="#panel2"></li>',
'</ul>',
'<div id="panel"></div>',
'<div id="panel2"></div>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')
const parent = fixtureEl.querySelector('.nav')
const children = fixtureEl.querySelectorAll('.nav-link')
const tabPanel = fixtureEl.querySelector('#panel')
const tabPanel2 = fixtureEl.querySelector('#panel2')
expect(parent.getAttribute('role')).toEqual(null)
expect(tabEl.getAttribute('role')).toEqual(null)
expect(tabPanel.getAttribute('role')).toEqual(null)
const tab = new Tab(tabEl)
tab._setInitialAttributes(parent, children)
expect(parent.getAttribute('role')).toEqual('tablist')
expect(tabEl.getAttribute('role')).toEqual('tab')
expect(tabPanel.getAttribute('role')).toEqual('tabpanel')
expect(tabPanel2.getAttribute('role')).toEqual('tabpanel')
expect(tabPanel.hasAttribute('tabindex')).toBeFalse()
expect(tabPanel.hasAttribute('tabindex2')).toBeFalse()
expect(tabPanel.getAttribute('aria-labelledby')).toEqual('#foo')
expect(tabPanel2.hasAttribute('aria-labelledby')).toBeFalse()
})
})
describe('_keydown', () => {
it('if event is not one of left/right/up/down arrow, ignore it', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" data-bs-toggle="tab"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')
const tab = new Tab(tabEl)
const keydown = createEvent('keydown')
keydown.key = 'Enter'
spyOn(Event.prototype, 'stopPropagation').and.callThrough()
spyOn(Event.prototype, 'preventDefault').and.callThrough()
spyOn(tab, '_keydown')
spyOn(tab, '_getChildren')
tabEl.dispatchEvent(keydown)
expect(tab._keydown).toHaveBeenCalled()
expect(tab._getChildren).not.toHaveBeenCalled()
expect(Event.prototype.stopPropagation).not.toHaveBeenCalled()
expect(Event.prototype.preventDefault).not.toHaveBeenCalled()
})
it('if keydown event is right/down arrow, handle it', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tab = new Tab(tabEl)
const tab2 = new Tab(tabEl2)
spyOn(tab, 'show').and.callThrough()
spyOn(tab2, 'show').and.callThrough()
spyOn(Event.prototype, 'stopPropagation').and.callThrough()
spyOn(Event.prototype, 'preventDefault').and.callThrough()
let keydown = createEvent('keydown')
keydown.key = 'ArrowRight'
tabEl.dispatchEvent(keydown)
expect(tab2.show).toHaveBeenCalled()
keydown = createEvent('keydown')
keydown.key = 'ArrowDown'
tabEl2.dispatchEvent(keydown)
expect(tab.show).toHaveBeenCalled()
expect(Event.prototype.stopPropagation).toHaveBeenCalledTimes(2)
expect(Event.prototype.preventDefault).toHaveBeenCalledTimes(2)
})
it('if keydown event is left arrow, handle it', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tab = new Tab(tabEl)
const tab2 = new Tab(tabEl2)
spyOn(tab, 'show').and.callThrough()
spyOn(tab2, 'show').and.callThrough()
spyOn(Event.prototype, 'stopPropagation').and.callThrough()
spyOn(Event.prototype, 'preventDefault').and.callThrough()
let keydown = createEvent('keydown')
keydown.key = 'ArrowLeft'
tabEl2.dispatchEvent(keydown)
expect(tab.show).toHaveBeenCalled()
keydown = createEvent('keydown')
keydown.key = 'ArrowUp'
tabEl.dispatchEvent(keydown)
expect(tab2.show).toHaveBeenCalled()
expect(Event.prototype.stopPropagation).toHaveBeenCalledTimes(2)
expect(Event.prototype.preventDefault).toHaveBeenCalledTimes(2)
})
})
describe('jQueryInterface', () => {
it('should create a tab', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
const div = fixtureEl.querySelector('.nav > div')
jQueryMock.fn.tab = Tab.jQueryInterface
jQueryMock.elements = [div]
@ -402,9 +565,9 @@ describe('Tab', () => {
})
it('should not re create a tab', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
const div = fixtureEl.querySelector('.nav > div')
const tab = new Tab(div)
jQueryMock.fn.tab = Tab.jQueryInterface
@ -416,9 +579,9 @@ describe('Tab', () => {
})
it('should call a tab method', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
const div = fixtureEl.querySelector('.nav > div')
const tab = new Tab(div)
spyOn(tab, 'show')
@ -433,9 +596,9 @@ describe('Tab', () => {
})
it('should throw error on undefined method', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
const div = fixtureEl.querySelector('.nav > div')
const action = 'undefinedMethod'
jQueryMock.fn.tab = Tab.jQueryInterface
@ -453,9 +616,9 @@ describe('Tab', () => {
})
it('should return this instance', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const divEl = fixtureEl.querySelector('div')
const divEl = fixtureEl.querySelector('.nav > div')
const tab = new Tab(divEl)
expect(Tab.getInstance(divEl)).toEqual(tab)
@ -465,7 +628,7 @@ describe('Tab', () => {
describe('getOrCreateInstance', () => {
it('should return tab instance', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
const tab = new Tab(div)
@ -476,7 +639,7 @@ describe('Tab', () => {
})
it('should return new instance when there is no tab instance', () => {
fixtureEl.innerHTML = '<div></div>'
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')
@ -651,11 +814,8 @@ describe('Tab', () => {
const tabProfileEl = fixtureEl.querySelector('#profile')
const tabHomeEl = fixtureEl.querySelector('#home')
triggerTabProfileEl.addEventListener('shown.bs.tab', () => {
expect(tabProfileEl).toHaveClass('fade')
expect(tabProfileEl).toHaveClass('show')
triggerTabHomeEl.addEventListener('shown.bs.tab', () => {
triggerTabHomeEl.addEventListener('shown.bs.tab', () => {
setTimeout(() => {
expect(tabProfileEl).toHaveClass('fade')
expect(tabProfileEl).not.toHaveClass('show')
@ -663,16 +823,22 @@ describe('Tab', () => {
expect(tabHomeEl).toHaveClass('show')
resolve()
})
}, 10)
})
triggerTabHomeEl.click()
triggerTabProfileEl.addEventListener('shown.bs.tab', () => {
setTimeout(() => {
expect(tabProfileEl).toHaveClass('fade')
expect(tabProfileEl).toHaveClass('show')
triggerTabHomeEl.click()
}, 10)
})
triggerTabProfileEl.click()
})
})
it('should not add show class to tab panes if there is no `.fade` class', () => {
it('should not add `show` class to tab panes if there is no `.fade` class', () => {
return new Promise(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
@ -720,8 +886,10 @@ describe('Tab', () => {
const secondNavEl = fixtureEl.querySelector('#secondNav')
secondNavEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelectorAll('.show')).toHaveSize(1)
resolve()
setTimeout(() => {
expect(fixtureEl.querySelectorAll('.show')).toHaveSize(1)
resolve()
}, 10)
})
secondNavEl.click()

View file

@ -332,12 +332,6 @@ On the `.nav-pills` modifier class:
Use the tab JavaScript plugin—include it individually or through the compiled `bootstrap.js` file—to extend our navigational tabs and pills to create tabbable panes of local content.
Dynamic tabbed interfaces, as described in the [<abbr title="Web Accessibility Initiative">WAI</abbr> <abbr title="Accessible Rich Internet Applications">ARIA</abbr> Authoring Practices](https://www.w3.org/TR/wai-aria-practices/#tabpanel), require `role="tablist"`, `role="tab"`, `role="tabpanel"`, and additional `aria-` attributes in order to convey their structure, functionality and current state to users of assistive technologies (such as screen readers). As a best practice, we recommend using `<button>` elements for the tabs, as these are controls that trigger a dynamic change, rather than links that navigate to a new page or location.
{{< callout danger >}}
Note that the tab JavaScript plugin **does not** support tabbed interfaces that contain dropdown menus, as these cause both usability and accessibility issues. From a usability perspective, the fact that the currently displayed tab's trigger element is not immediately visible (as it's inside the closed dropdown menu) can cause confusion. From an accessibility point of view, there is currently no sensible way to map this sort of construct to a standard WAI ARIA pattern, meaning that it cannot be easily made understandable to users of assistive technologies.
{{< /callout >}}
<div class="bd-example">
<ul class="nav nav-tabs mb-3" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
@ -351,14 +345,14 @@ Note that the tab JavaScript plugin **does not** support tabbed interfaces that
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab">
<p><strong>This is some placeholder content the Home tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
<p><strong>This is some placeholder content the Home tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation. No1</p>
</div>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab">
<p><strong>This is some placeholder content the Profile tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
<p><strong>This is some placeholder content the Profile tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation. No2</p>
</div>
<div class="tab-pane fade" id="contact" role="tabpanel" aria-labelledby="contact-tab">
<p><strong>This is some placeholder content the Contact tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
<div class="tab-pane fade" id="contact" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
<p><strong>This is some placeholder content the Contact tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation. No3</p>
</div>
</div>
</div>
@ -376,9 +370,9 @@ Note that the tab JavaScript plugin **does not** support tabbed interfaces that
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab">...</div>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab">...</div>
<div class="tab-pane fade" id="contact" role="tabpanel" aria-labelledby="contact-tab">...</div>
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="contact" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">...</div>
</div>
```
@ -393,13 +387,13 @@ To help fit your needs, this works with `<ul>`-based markup, as shown above, or
</div>
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">
<div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab" tabindex="0">
<p><strong>This is some placeholder content the Home tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab">
<div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab" tabindex="0">
<p><strong>This is some placeholder content the Profile tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="nav-contact" role="tabpanel" aria-labelledby="nav-contact-tab">
<div class="tab-pane fade" id="nav-contact" role="tabpanel" aria-labelledby="nav-contact-tab" tabindex="0">
<p><strong>This is some placeholder content the Contact tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
</div>
@ -414,9 +408,9 @@ To help fit your needs, this works with `<ul>`-based markup, as shown above, or
</div>
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">...</div>
<div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab">...</div>
<div class="tab-pane fade" id="nav-contact" role="tabpanel" aria-labelledby="nav-contact-tab">...</div>
<div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="nav-contact" role="tabpanel" aria-labelledby="nav-contact-tab" tabindex="0">...</div>
</div>
```
@ -435,13 +429,13 @@ The tabs plugin also works with pills.
</li>
</ul>
<div class="tab-content" id="pills-tabContent">
<div class="tab-pane fade show active" id="pills-home" role="tabpanel" aria-labelledby="pills-home-tab">
<div class="tab-pane fade show active" id="pills-home" role="tabpanel" aria-labelledby="pills-home-tab" tabindex="0">
<p><strong>This is some placeholder content the Home tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab">
<div class="tab-pane fade" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab" tabindex="0">
<p><strong>This is some placeholder content the Profile tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="pills-contact" role="tabpanel" aria-labelledby="pills-contact-tab">
<div class="tab-pane fade" id="pills-contact" role="tabpanel" aria-labelledby="pills-contact-tab" tabindex="0">
<p><strong>This is some placeholder content the Contact tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
</div>
@ -460,13 +454,13 @@ The tabs plugin also works with pills.
</li>
</ul>
<div class="tab-content" id="pills-tabContent">
<div class="tab-pane fade show active" id="pills-home" role="tabpanel" aria-labelledby="pills-home-tab">...</div>
<div class="tab-pane fade" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab">...</div>
<div class="tab-pane fade" id="pills-contact" role="tabpanel" aria-labelledby="pills-contact-tab">...</div>
<div class="tab-pane fade show active" id="pills-home" role="tabpanel" aria-labelledby="pills-home-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="pills-contact" role="tabpanel" aria-labelledby="pills-contact-tab" tabindex="0">...</div>
</div>
```
And with vertical pills.
And with vertical pills. Ideally, for vertical tabs, you should also add `aria-orientation="vertical"` to the tab list container.
<div class="bd-example">
<div class="d-flex align-items-start">
@ -477,16 +471,16 @@ And with vertical pills.
<button class="nav-link" id="v-pills-settings-tab" data-bs-toggle="pill" data-bs-target="#v-pills-settings" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false">Settings</button>
</div>
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab" tabindex="0">
<p><strong>This is some placeholder content the Home tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="v-pills-profile" role="tabpanel" aria-labelledby="v-pills-profile-tab">
<div class="tab-pane fade" id="v-pills-profile" role="tabpanel" aria-labelledby="v-pills-profile-tab" tabindex="0">
<p><strong>This is some placeholder content the Profile tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="v-pills-messages" role="tabpanel" aria-labelledby="v-pills-messages-tab">
<div class="tab-pane fade" id="v-pills-messages" role="tabpanel" aria-labelledby="v-pills-messages-tab" tabindex="0">
<p><strong>This is some placeholder content the Messages tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
<div class="tab-pane fade" id="v-pills-settings" role="tabpanel" aria-labelledby="v-pills-settings-tab">
<div class="tab-pane fade" id="v-pills-settings" role="tabpanel" aria-labelledby="v-pills-settings-tab" tabindex="0">
<p><strong>This is some placeholder content the Settings tab's associated content.</strong> Clicking another tab will toggle the visibility of this one for the next. The tab JavaScript swaps classes to control the content visibility and styling. You can use it with tabs, pills, and any other <code>.nav</code>-powered navigation.</p>
</div>
</div>
@ -502,14 +496,28 @@ And with vertical pills.
<button class="nav-link" id="v-pills-settings-tab" data-bs-toggle="pill" data-bs-target="#v-pills-settings" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false">Settings</button>
</div>
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">...</div>
<div class="tab-pane fade" id="v-pills-profile" role="tabpanel" aria-labelledby="v-pills-profile-tab">...</div>
<div class="tab-pane fade" id="v-pills-messages" role="tabpanel" aria-labelledby="v-pills-messages-tab">...</div>
<div class="tab-pane fade" id="v-pills-settings" role="tabpanel" aria-labelledby="v-pills-settings-tab">...</div>
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="v-pills-profile" role="tabpanel" aria-labelledby="v-pills-profile-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="v-pills-messages" role="tabpanel" aria-labelledby="v-pills-messages-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="v-pills-settings" role="tabpanel" aria-labelledby="v-pills-settings-tab" tabindex="0">...</div>
</div>
</div>
```
### Accessibility
Dynamic tabbed interfaces, as described in the [<abbr title="Web Accessibility Initiative">WAI</abbr> <abbr title="Accessible Rich Internet Applications">ARIA</abbr> Authoring Practices 1.2](https://www.w3.org/TR/wai-aria-practices-1.2/#tabpanel), require `role="tablist"`, `role="tab"`, `role="tabpanel"`, and additional `aria-` attributes in order to convey their structure, functionality, and current state to users of assistive technologies (such as screen readers). As a best practice, we recommend using `<button>` elements for the tabs, as these are controls that trigger a dynamic change, rather than links that navigate to a new page or location.
In line with the ARIA Authoring Practices pattern, only the currently active tab receives keyboard focus. When the JavaScript plugin is initialized, it will set `tabindex="-1"` on all inactive tab controls. Once the currently active tab has focus, the cursor keys activate the previous/next tab, with the plugin changing the [roving `tabindex`](https://www.w3.org/TR/wai-aria-practices-1.2/#kbd_roving_tabindex) accordingly. However, note that the JavaScript plugin does not distinguish between horizontal and vertical tab lists when it comes to cursor key interactions: regardless of the tab list's orientation, both the up *and* left cursor go to the previous tab, and down *and* right cursor go to the next tab.
{{< callout warning >}}
In general, to facilitate keyboard navigation, it's recommended to make the tab panels themselves focusable as well, unless the first element containing meaningful content inside the tab panel is already focusable. The JavaScript plugin does not try to handle this aspect—where appropriate, you'll need to explicitly make your tab panels focusable by adding `tabindex="0"` in your markup.
{{< /callout >}}
{{< callout danger >}}
The tab JavaScript plugin **does not** support tabbed interfaces that contain dropdown menus, as these cause both usability and accessibility issues. From a usability perspective, the fact that the currently displayed tab's trigger element is not immediately visible (as it's inside the closed dropdown menu) can cause confusion. From an accessibility point of view, there is currently no sensible way to map this sort of construct to a standard WAI ARIA pattern, meaning that it cannot be easily made understandable to users of assistive technologies.
{{< /callout >}}
### Using data attributes
You can activate a tab or pill navigation without writing any JavaScript by simply specifying `data-bs-toggle="tab"` or `data-bs-toggle="pill"` on an element. Use these data attributes on `.nav-tabs` or `.nav-pills`.
@ -533,10 +541,10 @@ You can activate a tab or pill navigation without writing any JavaScript by simp
<!-- Tab panes -->
<div class="tab-content">
<div class="tab-pane active" id="home" role="tabpanel" aria-labelledby="home-tab">...</div>
<div class="tab-pane" id="profile" role="tabpanel" aria-labelledby="profile-tab">...</div>
<div class="tab-pane" id="messages" role="tabpanel" aria-labelledby="messages-tab">...</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="settings-tab">...</div>
<div class="tab-pane active" id="home" role="tabpanel" aria-labelledby="home-tab" tabindex="0">...</div>
<div class="tab-pane" id="profile" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">...</div>
<div class="tab-pane" id="messages" role="tabpanel" aria-labelledby="messages-tab" tabindex="0">...</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="settings-tab" tabindex="0">...</div>
</div>
```
@ -572,10 +580,10 @@ To make tabs fade in, add `.fade` to each `.tab-pane`. The first tab pane must a
```html
<div class="tab-content">
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab">...</div>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab">...</div>
<div class="tab-pane fade" id="messages" role="tabpanel" aria-labelledby="messages-tab">...</div>
<div class="tab-pane fade" id="settings" role="tabpanel" aria-labelledby="settings-tab">...</div>
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="profile" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="messages" role="tabpanel" aria-labelledby="messages-tab" tabindex="0">...</div>
<div class="tab-pane fade" id="settings" role="tabpanel" aria-labelledby="settings-tab" tabindex="0">...</div>
</div>
```
@ -606,10 +614,10 @@ Activates a tab element and content container. Tab should have either a `data-bs
</ul>
<div class="tab-content">
<div class="tab-pane active" id="home" role="tabpanel" aria-labelledby="home-tab">...</div>
<div class="tab-pane" id="profile" role="tabpanel" aria-labelledby="profile-tab">...</div>
<div class="tab-pane" id="messages" role="tabpanel" aria-labelledby="messages-tab">...</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="settings-tab">...</div>
<div class="tab-pane active" id="home" role="tabpanel" aria-labelledby="home-tab" tabindex="0">...</div>
<div class="tab-pane" id="profile" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">...</div>
<div class="tab-pane" id="messages" role="tabpanel" aria-labelledby="messages-tab" tabindex="0">...</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="settings-tab" tabindex="0">...</div>
</div>
<script>