From 135b9cdff2bb301e36faa9110043d5823f620b56 Mon Sep 17 00:00:00 2001 From: GeoSot Date: Thu, 7 Apr 2022 02:36:08 +0300 Subject: [PATCH] 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 Co-authored-by: Patrick H. Lauke Co-authored-by: Mark Otto --- js/src/tab.js | 281 ++++++++++++------ js/tests/unit/tab.spec.js | 226 ++++++++++++-- site/content/docs/5.1/components/navs-tabs.md | 104 ++++--- 3 files changed, 450 insertions(+), 161 deletions(-) diff --git a/js/src/tab.js b/js/src/tab.js index f9969fb7a4ec..135e929dda3b 100644 --- a/js/src/tab.js +++ b/js/src/tab.js @@ -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,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 + } + + 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') + + 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]() }) } } @@ -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 */ diff --git a/js/tests/unit/tab.spec.js b/js/tests/unit/tab.spec.js index a0fb89bd4780..4b8fb62de6bd 100644 --- a/js/tests/unit/tab.spec.js +++ b/js/tests/unit/tab.spec.js @@ -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 = '
' + ].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 = '' + fixtureEl.innerHTML = '' - 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 = [ '' ].join('') @@ -374,10 +387,10 @@ describe('Tab', () => { describe('dispose', () => { it('should dispose a tab', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = '' - 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 = [ + '' + ].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 = [ + '', + '
', + '
' + ].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 = [ + '' + ].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 = [ + '' + ].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 = [ + '' + ].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 = '
' + fixtureEl.innerHTML = '' - 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 = '
' + fixtureEl.innerHTML = '' - 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 = '
' + fixtureEl.innerHTML = '' - 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 = '
' + fixtureEl.innerHTML = '' - 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 = '
' + fixtureEl.innerHTML = '' - 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 = '
' + fixtureEl.innerHTML = '' 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 = '
' + fixtureEl.innerHTML = '' 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 = [ '
-
...
-
...
-
...
+
...
+
...
+
...
``` @@ -393,13 +387,13 @@ To help fit your needs, this works with `
    `-based markup, as shown above, or
-
+

This is some placeholder content the Home tab's associated content. 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 .nav-powered navigation.

-
+

This is some placeholder content the Profile tab's associated content. 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 .nav-powered navigation.

-
+

This is some placeholder content the Contact tab's associated content. 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 .nav-powered navigation.

@@ -460,13 +454,13 @@ The tabs plugin also works with pills.
-
...
-
...
-
...
+
...
+
...
+
...
``` -And with vertical pills. +And with vertical pills. Ideally, for vertical tabs, you should also add `aria-orientation="vertical"` to the tab list container.
@@ -477,16 +471,16 @@ And with vertical pills.
-
+

This is some placeholder content the Home tab's associated content. 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 .nav-powered navigation.

-
+

This is some placeholder content the Profile tab's associated content. 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 .nav-powered navigation.

-
+

This is some placeholder content the Messages tab's associated content. 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 .nav-powered navigation.

-
+

This is some placeholder content the Settings tab's associated content. 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 .nav-powered navigation.

@@ -502,14 +496,28 @@ And with vertical pills.
-
...
-
...
-
...
-
...
+
...
+
...
+
...
+
...
``` +### Accessibility + +Dynamic tabbed interfaces, as described in the [WAI ARIA 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 `