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

Support Home and End keys in tabs #38498

Merged
merged 11 commits into from
Jul 23, 2023
15 changes: 12 additions & 3 deletions js/src/tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const HOME_KEY = 'Home'
const END_KEY = 'End'

const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_FADE = 'fade'
Expand Down Expand Up @@ -151,14 +153,21 @@ class Tab extends BaseComponent {
}

_keydown(event) {
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY, HOME_KEY, END_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().filter(element => !isDisabled(element)), event.target, isNext, true)

let nextActiveElement
const children = this._getChildren().filter(element => !isDisabled(element))
if ([HOME_KEY, END_KEY].includes(event.key)) {
nextActiveElement = children[event.key === HOME_KEY ? 0 : children.length - 1]
} else {
const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
nextActiveElement = getNextActiveElement(children, event.target, isNext, true)
}

if (nextActiveElement) {
nextActiveElement.focus({ preventScroll: true })
Expand Down
170 changes: 170 additions & 0 deletions js/tests/unit/tab.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,108 @@ describe('Tab', () => {
expect(spyPrevent).toHaveBeenCalledTimes(2)
})

it('if keydown event is Home, handle it', () => {
kyletsang marked this conversation as resolved.
Show resolved Hide resolved
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>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')

const tabEl1 = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tabEl3 = fixtureEl.querySelector('#tab3')
const tab1 = new Tab(tabEl1)
const tab2 = new Tab(tabEl2)
const tab3 = new Tab(tabEl3)
const spyShow1 = spyOn(tab1, 'show').and.callThrough()
const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
const spyShow2 = spyOn(tab2, 'show').and.callThrough()
const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
const spyShow3 = spyOn(tab3, 'show').and.callThrough()
const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()

const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()

let keydown = createEvent('keydown')
keydown.key = 'Home'

tabEl3.dispatchEvent(keydown)
expect(spyShow1).toHaveBeenCalled()
expect(spyFocus1).toHaveBeenCalled()
expect(spyShow2).not.toHaveBeenCalled()
expect(spyFocus2).not.toHaveBeenCalled()
expect(spyShow3).not.toHaveBeenCalled()
expect(spyFocus3).not.toHaveBeenCalled()

keydown = createEvent('keydown')
keydown.key = 'Home'

tabEl1.dispatchEvent(keydown)
expect(spyShow1).toHaveBeenCalled()
expect(spyFocus1).toHaveBeenCalled()
expect(spyShow2).not.toHaveBeenCalled()
expect(spyFocus2).not.toHaveBeenCalled()
expect(spyShow3).not.toHaveBeenCalled()
expect(spyFocus3).not.toHaveBeenCalled()

expect(spyStop).toHaveBeenCalledTimes(2)
expect(spyPrevent).toHaveBeenCalledTimes(2)
})

it('if keydown event is End, 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>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')

const tabEl1 = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tabEl3 = fixtureEl.querySelector('#tab3')
const tab1 = new Tab(tabEl1)
const tab2 = new Tab(tabEl2)
const tab3 = new Tab(tabEl3)
const spyShow1 = spyOn(tab1, 'show').and.callThrough()
const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
const spyShow2 = spyOn(tab2, 'show').and.callThrough()
const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
const spyShow3 = spyOn(tab3, 'show').and.callThrough()
const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()

const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()

let keydown = createEvent('keydown')
keydown.key = 'End'

tabEl1.dispatchEvent(keydown)
expect(spyShow1).not.toHaveBeenCalled()
expect(spyFocus1).not.toHaveBeenCalled()
expect(spyShow2).not.toHaveBeenCalled()
expect(spyFocus2).not.toHaveBeenCalled()
expect(spyShow3).toHaveBeenCalled()
expect(spyFocus3).toHaveBeenCalled()

keydown = createEvent('keydown')
keydown.key = 'End'

tabEl3.dispatchEvent(keydown)
expect(spyShow1).not.toHaveBeenCalled()
expect(spyFocus1).not.toHaveBeenCalled()
expect(spyShow2).not.toHaveBeenCalled()
expect(spyFocus2).not.toHaveBeenCalled()
expect(spyShow3).toHaveBeenCalled()
expect(spyFocus3).toHaveBeenCalled()

expect(spyStop).toHaveBeenCalledTimes(2)
expect(spyPrevent).toHaveBeenCalledTimes(2)
})

it('if keydown event is right arrow and next element is disabled', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
Expand Down Expand Up @@ -711,6 +813,74 @@ describe('Tab', () => {
expect(spyFocus2).not.toHaveBeenCalled()
expect(spyFocus1).toHaveBeenCalledTimes(1)
})

it('if keydown event is Home and first element is disabled', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link disabled" data-bs-toggle="tab" disabled></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')

const tabEl1 = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tabEl3 = fixtureEl.querySelector('#tab3')
const tab1 = new Tab(tabEl1)
const tab2 = new Tab(tabEl2)
const tab3 = new Tab(tabEl3)
const spy1 = spyOn(tab1, 'show').and.callThrough()
const spy2 = spyOn(tab2, 'show').and.callThrough()
const spy3 = spyOn(tab3, 'show').and.callThrough()
const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()

const keydown = createEvent('keydown')
keydown.key = 'Home'

tabEl3.dispatchEvent(keydown)
expect(spy3).not.toHaveBeenCalled()
expect(spy2).toHaveBeenCalledTimes(1)
expect(spy1).not.toHaveBeenCalled()
expect(spyFocus3).not.toHaveBeenCalled()
expect(spyFocus2).toHaveBeenCalledTimes(1)
expect(spyFocus1).not.toHaveBeenCalled()
})

it('if keydown event is End and last element is disabled', () => {
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>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab" disabled></span>',
'</div>'
].join('')

const tabEl1 = fixtureEl.querySelector('#tab1')
const tabEl2 = fixtureEl.querySelector('#tab2')
const tabEl3 = fixtureEl.querySelector('#tab3')
const tab1 = new Tab(tabEl1)
const tab2 = new Tab(tabEl2)
const tab3 = new Tab(tabEl3)
const spy1 = spyOn(tab1, 'show').and.callThrough()
const spy2 = spyOn(tab2, 'show').and.callThrough()
const spy3 = spyOn(tab3, 'show').and.callThrough()
const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()

const keydown = createEvent('keydown')
keydown.key = 'End'

tabEl1.dispatchEvent(keydown)
expect(spy3).not.toHaveBeenCalled()
expect(spy2).toHaveBeenCalledTimes(1)
expect(spy1).not.toHaveBeenCalled()
expect(spyFocus3).not.toHaveBeenCalled()
expect(spyFocus2).toHaveBeenCalledTimes(1)
expect(spyFocus1).not.toHaveBeenCalled()
})
})

describe('jQueryInterface', () => {
Expand Down
2 changes: 1 addition & 1 deletion site/content/docs/5.3/components/navs-tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ And with vertical pills. Ideally, for vertical tabs, you should also add `aria-o

Dynamic tabbed interfaces, as described in the [ARIA Authoring Practices Guide tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/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/WAI/ARIA/apg/practices/keyboard-interface/) 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.
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 and the <kbd>Home</kbd> and <kbd>End</kbd> keys activate the first and last tabs respectively. The plugin will change the [roving `tabindex`](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) 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.
mdo marked this conversation as resolved.
Show resolved Hide resolved

{{< 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.
Expand Down