Skip to content

Commit 3aec38b

Browse files
crisbetoandrewseguin
authored andcommitted
refactor(tabs): use common logic for handling keyboard focus (#9819)
Switches the tab header component to use the `FocusKeyManager` to manage the focused item, rather than implementing the logic itself.
1 parent 8f27b2a commit 3aec38b

File tree

3 files changed

+34
-74
lines changed

3 files changed

+34
-74
lines changed

src/lib/tabs/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ ng_module(
1515
] + glob(["**/*.html"]),
1616
deps = [
1717
"//src/lib/core",
18+
"//src/cdk/a11y",
1819
"//src/cdk/bidi",
1920
"//src/cdk/coercion",
2021
"//src/cdk/observers",

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ describe('MatTabHeader', () => {
4747
}));
4848

4949
describe('focusing', () => {
50+
let tabListContainer: HTMLElement;
51+
5052
beforeEach(() => {
5153
fixture = TestBed.createComponent(SimpleTabHeaderApp);
5254
fixture.detectChanges();
5355

5456
appComponent = fixture.componentInstance;
57+
tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
5558
});
5659

5760
it('should initialize to the selected index', () => {
@@ -83,12 +86,12 @@ describe('MatTabHeader', () => {
8386

8487
// Move focus right, verify that the disabled tab is 1 and should be skipped
8588
expect(appComponent.disabledTabIndex).toBe(1);
86-
appComponent.tabHeader._focusNextTab();
89+
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
8790
fixture.detectChanges();
8891
expect(appComponent.tabHeader.focusIndex).toBe(2);
8992

9093
// Move focus right to index 3
91-
appComponent.tabHeader._focusNextTab();
94+
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
9295
fixture.detectChanges();
9396
expect(appComponent.tabHeader.focusIndex).toBe(3);
9497
});
@@ -99,13 +102,13 @@ describe('MatTabHeader', () => {
99102
expect(appComponent.tabHeader.focusIndex).toBe(3);
100103

101104
// Move focus left to index 3
102-
appComponent.tabHeader._focusPreviousTab();
105+
dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW);
103106
fixture.detectChanges();
104107
expect(appComponent.tabHeader.focusIndex).toBe(2);
105108

106109
// Move focus left, verify that the disabled tab is 1 and should be skipped
107110
expect(appComponent.disabledTabIndex).toBe(1);
108-
appComponent.tabHeader._focusPreviousTab();
111+
dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW);
109112
fixture.detectChanges();
110113
expect(appComponent.tabHeader.focusIndex).toBe(0);
111114
});
@@ -115,8 +118,6 @@ describe('MatTabHeader', () => {
115118
fixture.detectChanges();
116119
expect(appComponent.tabHeader.focusIndex).toBe(0);
117120

118-
let tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
119-
120121
// Move focus right to 2
121122
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
122123
fixture.detectChanges();
@@ -147,7 +148,6 @@ describe('MatTabHeader', () => {
147148
fixture.detectChanges();
148149
expect(appComponent.tabHeader.focusIndex).toBe(3);
149150

150-
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
151151
const event = dispatchKeyboardEvent(tabListContainer, 'keydown', HOME);
152152
fixture.detectChanges();
153153

@@ -161,7 +161,6 @@ describe('MatTabHeader', () => {
161161
fixture.detectChanges();
162162
expect(appComponent.tabHeader.focusIndex).toBe(3);
163163

164-
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
165164
dispatchKeyboardEvent(tabListContainer, 'keydown', HOME);
166165
fixture.detectChanges();
167166

@@ -174,7 +173,6 @@ describe('MatTabHeader', () => {
174173
fixture.detectChanges();
175174
expect(appComponent.tabHeader.focusIndex).toBe(0);
176175

177-
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
178176
const event = dispatchKeyboardEvent(tabListContainer, 'keydown', END);
179177
fixture.detectChanges();
180178

@@ -188,7 +186,6 @@ describe('MatTabHeader', () => {
188186
fixture.detectChanges();
189187
expect(appComponent.tabHeader.focusIndex).toBe(0);
190188

191-
const tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement;
192189
dispatchKeyboardEvent(tabListContainer, 'keydown', END);
193190
fixture.detectChanges();
194191

src/lib/tabs/tab-header.ts

Lines changed: 26 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {Direction, Directionality} from '@angular/cdk/bidi';
1010
import {coerceNumberProperty} from '@angular/cdk/coercion';
11-
import {END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
11+
import {END, ENTER, HOME, SPACE} from '@angular/cdk/keycodes';
1212
import {ViewportRuler} from '@angular/cdk/scrolling';
1313
import {
1414
AfterContentChecked,
@@ -31,6 +31,7 @@ import {CanDisableRipple, mixinDisableRipple} from '@angular/material/core';
3131
import {merge, of as observableOf, Subscription} from 'rxjs';
3232
import {MatInkBar} from './ink-bar';
3333
import {MatTabLabelWrapper} from './tab-label-wrapper';
34+
import {FocusKeyManager} from '@angular/cdk/a11y';
3435

3536

3637
/**
@@ -80,9 +81,6 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
8081
@ViewChild('tabListContainer') _tabListContainer: ElementRef;
8182
@ViewChild('tabList') _tabList: ElementRef;
8283

83-
/** The tab index that is focused. */
84-
private _focusIndex: number = 0;
85-
8684
/** The distance in pixels that the tab labels should be translated to the left. */
8785
private _scrollDistance = 0;
8886

@@ -110,6 +108,9 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
110108
/** Whether the scroll distance has changed and should be applied after the view is checked. */
111109
private _scrollDistanceChanged: boolean;
112110

111+
/** Used to manage focus between the tabs. */
112+
private _keyManager: FocusKeyManager<MatTabLabelWrapper>;
113+
113114
private _selectedIndex: number = 0;
114115

115116
/** The index of the active tab. */
@@ -119,7 +120,10 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
119120
value = coerceNumberProperty(value);
120121
this._selectedIndexChanged = this._selectedIndex != value;
121122
this._selectedIndex = value;
122-
this._focusIndex = value;
123+
124+
if (this._keyManager) {
125+
this._keyManager.updateActiveItemIndex(value);
126+
}
123127
}
124128

125129
/** Event emitted when the option is selected. */
@@ -164,25 +168,21 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
164168

165169
_handleKeydown(event: KeyboardEvent) {
166170
switch (event.keyCode) {
167-
case RIGHT_ARROW:
168-
this._focusNextTab();
169-
break;
170-
case LEFT_ARROW:
171-
this._focusPreviousTab();
172-
break;
173171
case HOME:
174-
this._focusFirstTab();
172+
this._keyManager.setFirstItemActive();
175173
event.preventDefault();
176174
break;
177175
case END:
178-
this._focusLastTab();
176+
this._keyManager.setLastItemActive();
179177
event.preventDefault();
180178
break;
181179
case ENTER:
182180
case SPACE:
183181
this.selectFocusedIndex.emit(this.focusIndex);
184182
event.preventDefault();
185183
break;
184+
default:
185+
this._keyManager.onKeydown(event);
186186
}
187187
}
188188

@@ -197,10 +197,19 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
197197
this._alignInkBarToSelectedTab();
198198
};
199199

200+
this._keyManager = new FocusKeyManager(this._labelWrappers)
201+
.withHorizontalOrientation(this._getLayoutDirection());
202+
203+
this._keyManager.updateActiveItemIndex(0);
204+
200205
// Defer the first call in order to allow for slower browsers to lay out the elements.
201206
// This helps in cases where the user lands directly on a page with paginated tabs.
202207
typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(realign) : realign();
203-
this._realignInkBar = merge(dirChange, resize).subscribe(realign);
208+
209+
this._realignInkBar = merge(dirChange, resize).subscribe(() => {
210+
realign();
211+
this._keyManager.withHorizontalOrientation(this._getLayoutDirection());
212+
});
204213
}
205214

206215
ngOnDestroy() {
@@ -227,14 +236,14 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
227236

228237
/** Tracks which element has focus; used for keyboard navigation */
229238
get focusIndex(): number {
230-
return this._focusIndex;
239+
return this._keyManager ? this._keyManager.activeItemIndex! : 0;
231240
}
232241

233242
/** When the focus index is set, we must manually send focus to the correct label */
234243
set focusIndex(value: number) {
235-
if (!this._isValidIndex(value) || this._focusIndex == value) { return; }
244+
if (!this._isValidIndex(value) || this.focusIndex == value || !this._keyManager) { return; }
236245

237-
this._focusIndex = value;
246+
this._keyManager.setActiveItem(value);
238247
this.indexFocused.emit(value);
239248
this._setTabFocus(value);
240249
}
@@ -276,53 +285,6 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
276285
}
277286
}
278287

279-
/**
280-
* Moves the focus towards the beginning or the end of the list depending on the offset provided.
281-
* Valid offsets are 1 and -1.
282-
*/
283-
_moveFocus(offset: number) {
284-
if (this._labelWrappers) {
285-
const tabs: MatTabLabelWrapper[] = this._labelWrappers.toArray();
286-
287-
for (let i = this.focusIndex + offset; i < tabs.length && i >= 0; i += offset) {
288-
if (this._isValidIndex(i)) {
289-
this.focusIndex = i;
290-
return;
291-
}
292-
}
293-
}
294-
}
295-
296-
/** Increment the focus index by 1 until a valid tab is found. */
297-
_focusNextTab(): void {
298-
this._moveFocus(this._getLayoutDirection() == 'ltr' ? 1 : -1);
299-
}
300-
301-
/** Decrement the focus index by 1 until a valid tab is found. */
302-
_focusPreviousTab(): void {
303-
this._moveFocus(this._getLayoutDirection() == 'ltr' ? -1 : 1);
304-
}
305-
306-
/** Focuses the first tab. */
307-
private _focusFirstTab(): void {
308-
for (let i = 0; i < this._labelWrappers.length; i++) {
309-
if (this._isValidIndex(i)) {
310-
this.focusIndex = i;
311-
break;
312-
}
313-
}
314-
}
315-
316-
/** Focuses the last tab. */
317-
private _focusLastTab(): void {
318-
for (let i = this._labelWrappers.length - 1; i > -1; i--) {
319-
if (this._isValidIndex(i)) {
320-
this.focusIndex = i;
321-
break;
322-
}
323-
}
324-
}
325-
326288
/** The layout direction of the containing app. */
327289
_getLayoutDirection(): Direction {
328290
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';

0 commit comments

Comments
 (0)