Skip to content

Commit d45cc7e

Browse files
kyletsangpatrickhlaukeXhmikosRmdo
authored
Support Home and End keys in tabs (#38498)
* Support `Home` and `End` keys in tabs * Update tab.js * simplify tests * Update navs-tabs.md * Update .bundlewatch.config.json --------- Co-authored-by: Patrick H. Lauke <redux@splintered.co.uk> Co-authored-by: XhmikosR <xhmikosr@gmail.com> Co-authored-by: Mark Otto <markdotto@gmail.com>
1 parent 8fcfce1 commit d45cc7e

File tree

4 files changed

+127
-5
lines changed

4 files changed

+127
-5
lines changed

.bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
},
5555
{
5656
"path": "./dist/js/bootstrap.min.js",
57-
"maxSize": "16.1 kB"
57+
"maxSize": "16.25 kB"
5858
}
5959
],
6060
"ci": {

js/src/tab.js

+13-3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const ARROW_LEFT_KEY = 'ArrowLeft'
3030
const ARROW_RIGHT_KEY = 'ArrowRight'
3131
const ARROW_UP_KEY = 'ArrowUp'
3232
const ARROW_DOWN_KEY = 'ArrowDown'
33+
const HOME_KEY = 'Home'
34+
const END_KEY = 'End'
3335

3436
const CLASS_NAME_ACTIVE = 'active'
3537
const CLASS_NAME_FADE = 'fade'
@@ -151,14 +153,22 @@ class Tab extends BaseComponent {
151153
}
152154

153155
_keydown(event) {
154-
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
156+
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY, HOME_KEY, END_KEY].includes(event.key))) {
155157
return
156158
}
157159

158160
event.stopPropagation()// stopPropagation/preventDefault both added to support up/down keys without scrolling the page
159161
event.preventDefault()
160-
const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
161-
const nextActiveElement = getNextActiveElement(this._getChildren().filter(element => !isDisabled(element)), event.target, isNext, true)
162+
163+
const children = this._getChildren().filter(element => !isDisabled(element))
164+
let nextActiveElement
165+
166+
if ([HOME_KEY, END_KEY].includes(event.key)) {
167+
nextActiveElement = children[event.key === HOME_KEY ? 0 : children.length - 1]
168+
} else {
169+
const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
170+
nextActiveElement = getNextActiveElement(children, event.target, isNext, true)
171+
}
162172

163173
if (nextActiveElement) {
164174
nextActiveElement.focus({ preventScroll: true })

js/tests/unit/tab.spec.js

+112
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,58 @@ describe('Tab', () => {
630630
expect(spyPrevent).toHaveBeenCalledTimes(2)
631631
})
632632

633+
it('if keydown event is Home, handle it', () => {
634+
fixtureEl.innerHTML = [
635+
'<div class="nav">',
636+
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
637+
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
638+
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
639+
'</div>'
640+
].join('')
641+
642+
const tabEl1 = fixtureEl.querySelector('#tab1')
643+
const tabEl3 = fixtureEl.querySelector('#tab3')
644+
645+
const tab3 = new Tab(tabEl3)
646+
tab3.show()
647+
648+
const spyShown = jasmine.createSpy()
649+
tabEl1.addEventListener('shown.bs.tab', spyShown)
650+
651+
const keydown = createEvent('keydown')
652+
keydown.key = 'Home'
653+
654+
tabEl3.dispatchEvent(keydown)
655+
656+
expect(spyShown).toHaveBeenCalled()
657+
})
658+
659+
it('if keydown event is End, handle it', () => {
660+
fixtureEl.innerHTML = [
661+
'<div class="nav">',
662+
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
663+
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
664+
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
665+
'</div>'
666+
].join('')
667+
668+
const tabEl1 = fixtureEl.querySelector('#tab1')
669+
const tabEl3 = fixtureEl.querySelector('#tab3')
670+
671+
const tab1 = new Tab(tabEl1)
672+
tab1.show()
673+
674+
const spyShown = jasmine.createSpy()
675+
tabEl3.addEventListener('shown.bs.tab', spyShown)
676+
677+
const keydown = createEvent('keydown')
678+
keydown.key = 'End'
679+
680+
tabEl1.dispatchEvent(keydown)
681+
682+
expect(spyShown).toHaveBeenCalled()
683+
})
684+
633685
it('if keydown event is right arrow and next element is disabled', () => {
634686
fixtureEl.innerHTML = [
635687
'<div class="nav">',
@@ -711,6 +763,66 @@ describe('Tab', () => {
711763
expect(spyFocus2).not.toHaveBeenCalled()
712764
expect(spyFocus1).toHaveBeenCalledTimes(1)
713765
})
766+
767+
it('if keydown event is Home and first element is disabled', () => {
768+
fixtureEl.innerHTML = [
769+
'<div class="nav">',
770+
' <span id="tab1" class="nav-link disabled" data-bs-toggle="tab" disabled></span>',
771+
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
772+
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
773+
'</div>'
774+
].join('')
775+
776+
const tabEl1 = fixtureEl.querySelector('#tab1')
777+
const tabEl2 = fixtureEl.querySelector('#tab2')
778+
const tabEl3 = fixtureEl.querySelector('#tab3')
779+
const tab3 = new Tab(tabEl3)
780+
781+
tab3.show()
782+
783+
const spyShown1 = jasmine.createSpy()
784+
const spyShown2 = jasmine.createSpy()
785+
tabEl1.addEventListener('shown.bs.tab', spyShown1)
786+
tabEl2.addEventListener('shown.bs.tab', spyShown2)
787+
788+
const keydown = createEvent('keydown')
789+
keydown.key = 'Home'
790+
791+
tabEl3.dispatchEvent(keydown)
792+
793+
expect(spyShown1).not.toHaveBeenCalled()
794+
expect(spyShown2).toHaveBeenCalled()
795+
})
796+
797+
it('if keydown event is End and last element is disabled', () => {
798+
fixtureEl.innerHTML = [
799+
'<div class="nav">',
800+
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
801+
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
802+
' <span id="tab3" class="nav-link" data-bs-toggle="tab" disabled></span>',
803+
'</div>'
804+
].join('')
805+
806+
const tabEl1 = fixtureEl.querySelector('#tab1')
807+
const tabEl2 = fixtureEl.querySelector('#tab2')
808+
const tabEl3 = fixtureEl.querySelector('#tab3')
809+
const tab1 = new Tab(tabEl1)
810+
811+
tab1.show()
812+
813+
const spyShown2 = jasmine.createSpy()
814+
const spyShown3 = jasmine.createSpy()
815+
tabEl2.addEventListener('shown.bs.tab', spyShown2)
816+
tabEl3.addEventListener('shown.bs.tab', spyShown3)
817+
818+
const keydown = createEvent('keydown')
819+
keydown.key = 'End'
820+
821+
tabEl1.dispatchEvent(keydown)
822+
823+
expect(spyShown3).not.toHaveBeenCalled()
824+
expect(spyShown2).toHaveBeenCalled()
825+
})
714826
})
715827

716828
describe('jQueryInterface', () => {

site/content/docs/5.3/components/navs-tabs.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ And with vertical pills. Ideally, for vertical tabs, you should also add `aria-o
567567

568568
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.
569569

570-
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.
570+
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. 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.
571571

572572
{{< callout warning >}}
573573
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.

0 commit comments

Comments
 (0)