Skip to content

Commit 24f62eb

Browse files
crisbetojelbourn
authored andcommitted
fix(tabs): move focus to first/last tabs using home/end (#9171)
Implements the ability to focus the first and last tabs using the home and end keys, based on the [accessibility guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel).
1 parent 8271352 commit 24f62eb

File tree

3 files changed

+93
-3
lines changed

3 files changed

+93
-3
lines changed

src/lib/tabs/tab-header.spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import {
2-
async, ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks
2+
async,
3+
ComponentFixture,
4+
TestBed,
5+
fakeAsync,
6+
tick,
7+
discardPeriodicTasks,
38
} from '@angular/core/testing';
49
import {Component, ViewChild} from '@angular/core';
510
import {CommonModule} from '@angular/common';
611
import {By} from '@angular/platform-browser';
7-
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
12+
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, HOME, END} from '@angular/cdk/keycodes';
813
import {PortalModule} from '@angular/cdk/portal';
914
import {Direction, Directionality} from '@angular/cdk/bidi';
1015
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
@@ -136,6 +141,60 @@ describe('MatTabHeader', () => {
136141
expect(appComponent.selectedIndex).toBe(0);
137142
expect(spaceEvent.defaultPrevented).toBe(true);
138143
});
144+
145+
it('should move focus to the first tab when pressing HOME', () => {
146+
appComponent.tabHeader.focusIndex = 3;
147+
fixture.detectChanges();
148+
expect(appComponent.tabHeader.focusIndex).toBe(3);
149+
150+
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
151+
const event = dispatchKeyboardEvent(tabListContainer, 'keydown', HOME);
152+
fixture.detectChanges();
153+
154+
expect(appComponent.tabHeader.focusIndex).toBe(0);
155+
expect(event.defaultPrevented).toBe(true);
156+
});
157+
158+
it('should skip disabled items when moving focus using HOME', () => {
159+
appComponent.tabHeader.focusIndex = 3;
160+
appComponent.tabs[0].disabled = true;
161+
fixture.detectChanges();
162+
expect(appComponent.tabHeader.focusIndex).toBe(3);
163+
164+
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
165+
dispatchKeyboardEvent(tabListContainer, 'keydown', HOME);
166+
fixture.detectChanges();
167+
168+
// Note that the second tab is disabled by default already.
169+
expect(appComponent.tabHeader.focusIndex).toBe(2);
170+
});
171+
172+
it('should move focus to the last tab when pressing END', () => {
173+
appComponent.tabHeader.focusIndex = 0;
174+
fixture.detectChanges();
175+
expect(appComponent.tabHeader.focusIndex).toBe(0);
176+
177+
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
178+
const event = dispatchKeyboardEvent(tabListContainer, 'keydown', END);
179+
fixture.detectChanges();
180+
181+
expect(appComponent.tabHeader.focusIndex).toBe(3);
182+
expect(event.defaultPrevented).toBe(true);
183+
});
184+
185+
it('should skip disabled items when moving focus using END', () => {
186+
appComponent.tabHeader.focusIndex = 0;
187+
appComponent.tabs[3].disabled = true;
188+
fixture.detectChanges();
189+
expect(appComponent.tabHeader.focusIndex).toBe(0);
190+
191+
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
192+
dispatchKeyboardEvent(tabListContainer, 'keydown', END);
193+
fixture.detectChanges();
194+
195+
expect(appComponent.tabHeader.focusIndex).toBe(2);
196+
});
197+
139198
});
140199

141200
describe('pagination', () => {

src/lib/tabs/tab-header.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {Direction, Directionality} from '@angular/cdk/bidi';
10-
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
10+
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, HOME, END} from '@angular/cdk/keycodes';
1111
import {
1212
AfterContentChecked,
1313
AfterContentInit,
@@ -173,6 +173,14 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
173173
case LEFT_ARROW:
174174
this._focusPreviousTab();
175175
break;
176+
case HOME:
177+
this._focusFirstTab();
178+
event.preventDefault();
179+
break;
180+
case END:
181+
this._focusLastTab();
182+
event.preventDefault();
183+
break;
176184
case ENTER:
177185
case SPACE:
178186
this.selectFocusedIndex.emit(this.focusIndex);
@@ -296,6 +304,26 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
296304
this._moveFocus(this._getLayoutDirection() == 'ltr' ? -1 : 1);
297305
}
298306

307+
/** Focuses the first tab. */
308+
private _focusFirstTab(): void {
309+
for (let i = 0; i < this._labelWrappers.length; i++) {
310+
if (this._isValidIndex(i)) {
311+
this.focusIndex = i;
312+
break;
313+
}
314+
}
315+
}
316+
317+
/** Focuses the last tab. */
318+
private _focusLastTab(): void {
319+
for (let i = this._labelWrappers.length - 1; i > -1; i--) {
320+
if (this._isValidIndex(i)) {
321+
this.focusIndex = i;
322+
break;
323+
}
324+
}
325+
}
326+
299327
/** The layout direction of the containing app. */
300328
_getLayoutDirection(): Direction {
301329
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';

src/lib/tabs/tabs.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,7 @@ Tabs without text or labels should be given a meaningful label via `aria-label`
9191
|----------------------|----------------------------|
9292
| `LEFT_ARROW` | Move focus to previous tab |
9393
| `RIGHT_ARROW` | Move focus to next tab |
94+
| `HOME` | Move focus to first tab |
95+
| `END` | Move focus to last tab |
96+
| `RIGHT_ARROW` | Move focus to next tab |
9497
| `SPACE` or `ENTER` | Switch to focused tab |

0 commit comments

Comments
 (0)