]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Support `Home` and `End` keys in tabs (#38498)
authorKyle Tsang <6854874+kyletsang@users.noreply.github.com>
Sun, 23 Jul 2023 19:06:14 +0000 (12:06 -0700)
committerGitHub <noreply@github.com>
Sun, 23 Jul 2023 19:06:14 +0000 (22:06 +0300)
* 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>
.bundlewatch.config.json
js/src/tab.js
js/tests/unit/tab.spec.js
site/content/docs/5.3/components/navs-tabs.md

index 01ccb8deed7c884a1b2d549f63c7cd8b076a0e3f..4e167c9cc703fbd2a04a838d1532a3bfe0cf547e 100644 (file)
@@ -54,7 +54,7 @@
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "16.1 kB"
+      "maxSize": "16.25 kB"
     }
   ],
   "ci": {
index d9993d56e8dff6f990c3615fc1a94f2c7c8e8538..5598e15c02439df07d5a65f727b34bdef1131aed 100644 (file)
@@ -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'
@@ -151,14 +153,22 @@ 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)
+
+    const children = this._getChildren().filter(element => !isDisabled(element))
+    let nextActiveElement
+
+    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 })
index 84690fc51fc058325a58e7d1b2ef809c6f03a13d..007adddc601484fe3f015149ee093d02e967f515 100644 (file)
@@ -630,6 +630,58 @@ describe('Tab', () => {
       expect(spyPrevent).toHaveBeenCalledTimes(2)
     })
 
+    it('if keydown event is Home, 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 tabEl3 = fixtureEl.querySelector('#tab3')
+
+      const tab3 = new Tab(tabEl3)
+      tab3.show()
+
+      const spyShown = jasmine.createSpy()
+      tabEl1.addEventListener('shown.bs.tab', spyShown)
+
+      const keydown = createEvent('keydown')
+      keydown.key = 'Home'
+
+      tabEl3.dispatchEvent(keydown)
+
+      expect(spyShown).toHaveBeenCalled()
+    })
+
+    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 tabEl3 = fixtureEl.querySelector('#tab3')
+
+      const tab1 = new Tab(tabEl1)
+      tab1.show()
+
+      const spyShown = jasmine.createSpy()
+      tabEl3.addEventListener('shown.bs.tab', spyShown)
+
+      const keydown = createEvent('keydown')
+      keydown.key = 'End'
+
+      tabEl1.dispatchEvent(keydown)
+
+      expect(spyShown).toHaveBeenCalled()
+    })
+
     it('if keydown event is right arrow and next element is disabled', () => {
       fixtureEl.innerHTML = [
         '<div class="nav">',
@@ -711,6 +763,66 @@ 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 tab3 = new Tab(tabEl3)
+
+      tab3.show()
+
+      const spyShown1 = jasmine.createSpy()
+      const spyShown2 = jasmine.createSpy()
+      tabEl1.addEventListener('shown.bs.tab', spyShown1)
+      tabEl2.addEventListener('shown.bs.tab', spyShown2)
+
+      const keydown = createEvent('keydown')
+      keydown.key = 'Home'
+
+      tabEl3.dispatchEvent(keydown)
+
+      expect(spyShown1).not.toHaveBeenCalled()
+      expect(spyShown2).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)
+
+      tab1.show()
+
+      const spyShown2 = jasmine.createSpy()
+      const spyShown3 = jasmine.createSpy()
+      tabEl2.addEventListener('shown.bs.tab', spyShown2)
+      tabEl3.addEventListener('shown.bs.tab', spyShown3)
+
+      const keydown = createEvent('keydown')
+      keydown.key = 'End'
+
+      tabEl1.dispatchEvent(keydown)
+
+      expect(spyShown3).not.toHaveBeenCalled()
+      expect(spyShown2).toHaveBeenCalled()
+    })
   })
 
   describe('jQueryInterface', () => {
index 0a99e34f8fc5302025c75c1d0043ac5b4286f7e7..5cf75e14503869e01784c14bd6349b25f9d84eab 100644 (file)
@@ -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. 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.
 
 {{< 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.