Skip to content

Commit d29df38

Browse files
vanessanschmittjelbourn
authored andcommitted
feat(material-experimental/chips): add grid keyboard shortcuts (#16384)
1 parent aa22368 commit d29df38

15 files changed

+773
-209
lines changed

src/material-experimental/mdc-chips/chip-default-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {InjectionToken} from '@angular/core';
1010

11+
1112
/** Default options, for the chips module, that can be overridden. */
1213
export interface MatChipsDefaultOptions {
1314
/** The list of key codes that will trigger a chipEnd event. */

src/material-experimental/mdc-chips/chip-grid.ts

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,24 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {Directionality} from '@angular/cdk/bidi';
910
import {coerceBooleanProperty} from '@angular/cdk/coercion';
10-
import {BACKSPACE} from '@angular/cdk/keycodes';
11+
import {BACKSPACE, TAB} from '@angular/cdk/keycodes';
1112
import {
1213
AfterContentInit,
1314
AfterViewInit,
1415
ChangeDetectionStrategy,
1516
ChangeDetectorRef,
1617
Component,
18+
ContentChildren,
1719
DoCheck,
1820
ElementRef,
1921
EventEmitter,
2022
Input,
2123
OnDestroy,
2224
Optional,
2325
Output,
26+
QueryList,
2427
Self,
2528
ViewEncapsulation
2629
} from '@angular/core';
@@ -35,9 +38,10 @@ import {MatFormFieldControl} from '@angular/material/form-field';
3538
import {MatChipTextControl} from './chip-text-control';
3639
import {merge, Observable, Subscription} from 'rxjs';
3740
import {startWith, takeUntil} from 'rxjs/operators';
38-
3941
import {MatChipEvent} from './chip';
42+
import {MatChipRow} from './chip-row';
4043
import {MatChipSet} from './chip-set';
44+
import {GridFocusKeyManager} from './grid-focus-key-manager';
4145

4246

4347
/** Change event object that is emitted when the chip grid value has changed. */
@@ -76,10 +80,11 @@ const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase =
7680
selector: 'mat-chip-grid',
7781
template: '<ng-content></ng-content>',
7882
styleUrls: ['chips.css'],
83+
inputs: ['tabIndex'],
7984
host: {
8085
'class': 'mat-mdc-chip-set mat-mdc-chip-grid mdc-chip-set',
8186
'role': 'grid',
82-
'[tabIndex]': 'empty ? -1 : 0',
87+
'[tabIndex]': 'tabIndex',
8388
// TODO: replace this binding with use of AriaDescriber
8489
'[attr.aria-describedby]': '_ariaDescribedby || null',
8590
'[attr.aria-required]': 'required.toString()',
@@ -88,6 +93,9 @@ const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase =
8893
'[class.mat-mdc-chip-list-disabled]': 'disabled',
8994
'[class.mat-mdc-chip-list-invalid]': 'errorState',
9095
'[class.mat-mdc-chip-list-required]': 'required',
96+
'(focus)': 'focus()',
97+
'(blur)': '_blur()',
98+
'(keydown)': '_keydown($event)',
9199
'[id]': '_uid',
92100
},
93101
providers: [{provide: MatFormFieldControl, useExisting: MatChipGrid}],
@@ -105,6 +113,9 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
105113
/** Subscription to blur changes in the chips. */
106114
private _chipBlurSubscription: Subscription | null;
107115

116+
/** Subscription to focus changes in the chips. */
117+
private _chipFocusSubscription: Subscription | null;
118+
108119
/** The chip input to add more chips */
109120
protected _chipInput: MatChipTextControl;
110121

@@ -120,6 +131,9 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
120131
*/
121132
_onChange: (value: any) => void = () => {};
122133

134+
/** The GridFocusKeyManager which handles focus. */
135+
_keyManager: GridFocusKeyManager;
136+
123137
/**
124138
* Implemented as part of MatFormFieldControl.
125139
* @docs-private
@@ -187,6 +201,11 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
187201
return merge(...this._chips.map(chip => chip._onBlur));
188202
}
189203

204+
/** Combined stream of all of the child chips' focus events. */
205+
get chipFocusChanges(): Observable<MatChipEvent> {
206+
return merge(...this._chips.map(chip => chip._onFocus));
207+
}
208+
190209
/** Emits when the chip grid value has been changed by the user. */
191210
@Output() readonly change: EventEmitter<MatChipGridChange> =
192211
new EventEmitter<MatChipGridChange>();
@@ -198,8 +217,16 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
198217
*/
199218
@Output() readonly valueChange: EventEmitter<any> = new EventEmitter<any>();
200219

220+
@ContentChildren(MatChipRow, {
221+
// We need to use `descendants: true`, because Ivy will no longer match
222+
// indirect descendants if it's left as false.
223+
descendants: true
224+
})
225+
_rowChips: QueryList<MatChipRow>;
226+
201227
constructor(_elementRef: ElementRef,
202228
_changeDetectorRef: ChangeDetectorRef,
229+
@Optional() private _dir: Directionality,
203230
@Optional() _parentForm: NgForm,
204231
@Optional() _parentFormGroup: FormGroupDirective,
205232
_defaultErrorStateMatcher: ErrorStateMatcher,
@@ -214,7 +241,11 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
214241

215242
ngAfterContentInit() {
216243
super.ngAfterContentInit();
244+
this._initKeyManager();
245+
217246
this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
247+
this._updateTabIndex();
248+
218249
// Check to see if we have a destroyed chip and need to refocus
219250
this._updateFocusForDestroyedChips();
220251

@@ -269,7 +300,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
269300
}
270301

271302
if (this._chips.length > 0) {
272-
this._chips.toArray()[0].focus();
303+
this._keyManager.setFirstCellActive();
273304
} else {
274305
this._focusInput();
275306
}
@@ -320,6 +351,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
320351
// Timeout is needed to wait for the focus() event trigger on chip input.
321352
setTimeout(() => {
322353
if (!this.focused) {
354+
this._keyManager.setActiveCell({row: -1, column: -1});
323355
this._propagateChanges();
324356
this._markAsTouched();
325357
}
@@ -332,7 +364,18 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
332364
* it back to the first chip, creating a focus trap, if it user tries to tab away.
333365
*/
334366
_allowFocusEscape() {
335-
// TODO
367+
if (this._chipInput.focused) {
368+
return;
369+
}
370+
371+
if (this.tabIndex !== -1) {
372+
this.tabIndex = -1;
373+
374+
setTimeout(() => {
375+
this.tabIndex = 0;
376+
this._changeDetectorRef.markForCheck();
377+
});
378+
}
336379
}
337380

338381
/** Handles custom keyboard events. */
@@ -342,11 +385,15 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
342385
// If they are on an empty input and hit backspace, focus the last chip
343386
if (event.keyCode === BACKSPACE && this._isEmptyInput(target)) {
344387
if (this._chips.length) {
345-
this._chips.toArray()[this._chips.length - 1].focus();
388+
this._keyManager.setLastCellActive();
346389
}
347390
event.preventDefault();
391+
} else if (event.keyCode === TAB) {
392+
this._allowFocusEscape();
393+
} else {
394+
this._keyManager.onKeydown(event);
348395
}
349-
this.stateChanges.next();
396+
this.stateChanges.next();
350397
}
351398

352399
/** Unsubscribes from all chip events. */
@@ -356,14 +403,43 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
356403
this._chipBlurSubscription.unsubscribe();
357404
this._chipBlurSubscription = null;
358405
}
406+
407+
if (this._chipFocusSubscription) {
408+
this._chipFocusSubscription.unsubscribe();
409+
this._chipFocusSubscription = null;
410+
}
359411
}
360412

361413
/** Subscribes to events on the child chips. */
362414
protected _subscribeToChipEvents() {
363415
super._subscribeToChipEvents();
416+
this._listenToChipsFocus();
364417
this._listenToChipsBlur();
365418
}
366419

420+
/** Initializes the key manager to manage focus. */
421+
private _initKeyManager() {
422+
this._keyManager = new GridFocusKeyManager(this._rowChips)
423+
.withDirectionality(this._dir ? this._dir.value : 'ltr');
424+
425+
if (this._dir) {
426+
this._dir.change
427+
.pipe(takeUntil(this._destroyed))
428+
.subscribe(dir => this._keyManager.withDirectionality(dir));
429+
}
430+
}
431+
432+
/** Subscribes to chip focus events. */
433+
private _listenToChipsFocus(): void {
434+
this._chipFocusSubscription = this.chipFocusChanges.subscribe((event: MatChipEvent) => {
435+
let chipIndex: number = this._chips.toArray().indexOf(event.chip);
436+
437+
if (this._isValidIndex(chipIndex)) {
438+
this._keyManager.updateActiveCell({row: chipIndex, column: 0});
439+
}
440+
});
441+
}
442+
367443
/** Subscribes to chip blur events. */
368444
private _listenToChipsBlur(): void {
369445
this._chipBlurSubscription = this.chipBlurChanges.subscribe(() => {
@@ -409,17 +485,23 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
409485
* If the amount of chips changed, we need to focus the next closest chip.
410486
*/
411487
private _updateFocusForDestroyedChips() {
488+
// Wait for chips to be updated in keyManager
489+
setTimeout(() => {
412490
// Move focus to the closest chip. If no other chips remain, focus the chip-grid itself.
413491
if (this._lastDestroyedChipIndex != null) {
414492
if (this._chips.length) {
415493
const newChipIndex = Math.min(this._lastDestroyedChipIndex, this._chips.length - 1);
416-
this._chips.toArray()[newChipIndex].focus();
494+
this._keyManager.setActiveCell({
495+
row: newChipIndex,
496+
column: this._keyManager.activeColumnIndex
497+
});
417498
} else {
418499
this.focus();
419500
}
420501
}
421502

422503
this._lastDestroyedChipIndex = null;
504+
});
423505
}
424506

425507
/** Focus input element. */
@@ -436,4 +518,12 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
436518

437519
return false;
438520
}
521+
522+
/**
523+
* Check the tab index as you should not be allowed to focus an empty grid.
524+
*/
525+
protected _updateTabIndex(): void {
526+
// If we have 0 chips, we should not allow keyboard focus
527+
this.tabIndex = this._chips.length === 0 ? -1 : 0;
528+
}
439529
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
ChangeDetectorRef,
11+
Directive,
12+
ElementRef,
13+
} from '@angular/core';
14+
import {
15+
CanDisable,
16+
CanDisableCtor,
17+
HasTabIndex,
18+
HasTabIndexCtor,
19+
mixinDisabled,
20+
mixinTabIndex,
21+
} from '@angular/material/core';
22+
import {Subject} from 'rxjs';
23+
24+
25+
/**
26+
* Directive to add CSS classes to chip leading icon.
27+
* @docs-private
28+
*/
29+
@Directive({
30+
selector: 'mat-chip-avatar, [matChipAvatar]',
31+
host: {
32+
'class': 'mat-mdc-chip-avatar mdc-chip__icon mdc-chip__icon--leading',
33+
'role': 'img'
34+
}
35+
})
36+
export class MatChipAvatar {
37+
constructor(private _changeDetectorRef: ChangeDetectorRef,
38+
private _elementRef: ElementRef) {}
39+
40+
/** Sets whether the given CSS class should be applied to the leading icon. */
41+
setClass(cssClass: string, active: boolean) {
42+
const element = this._elementRef.nativeElement;
43+
active ? element.addClass(cssClass) : element.removeClass(cssClass);
44+
this._changeDetectorRef.markForCheck();
45+
}
46+
}
47+
48+
/**
49+
* Directive to add CSS classes to and configure attributes for chip trailing icon.
50+
* @docs-private
51+
*/
52+
@Directive({
53+
selector: 'mat-chip-trailing-icon, [matChipTrailingIcon]',
54+
host: {
55+
'class': 'mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing',
56+
'tabindex': '-1',
57+
'aria-hidden': 'true',
58+
}
59+
})
60+
export class MatChipTrailingIcon {
61+
}
62+
63+
/**
64+
* Boilerplate for applying mixins to MatChipRemove.
65+
* @docs-private
66+
*/
67+
class MatChipRemoveBase extends MatChipTrailingIcon {
68+
constructor(public _elementRef: ElementRef) {
69+
super();
70+
}
71+
}
72+
73+
const _MatChipRemoveMixinBase:
74+
CanDisableCtor &
75+
HasTabIndexCtor &
76+
typeof MatChipRemoveBase =
77+
mixinTabIndex(mixinDisabled(MatChipRemoveBase));
78+
79+
/**
80+
* Directive to remove the parent chip when the trailing icon is clicked or
81+
* when the ENTER key is pressed on it.
82+
*
83+
* Recommended for use with the Material Design "cancel" icon
84+
* available at https://material.io/icons/#ic_cancel.
85+
*
86+
* Example:
87+
*
88+
* `<mat-chip>
89+
* <mat-icon matChipRemove>cancel</mat-icon>
90+
* </mat-chip>`
91+
*/
92+
@Directive({
93+
selector: '[matChipRemove]',
94+
inputs: ['disabled', 'tabIndex'],
95+
host: {
96+
'class': 'mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing',
97+
'[tabIndex]': 'tabIndex',
98+
'role': 'button',
99+
'(click)': 'interaction.next($event)',
100+
'(keydown)': 'interaction.next($event)',
101+
}
102+
})
103+
export class MatChipRemove extends _MatChipRemoveMixinBase implements CanDisable, HasTabIndex {
104+
/**
105+
* Emits when the user interacts with the icon.
106+
* @docs-private
107+
*/
108+
interaction: Subject<MouseEvent | KeyboardEvent> = new Subject<MouseEvent | KeyboardEvent>();
109+
110+
constructor(_elementRef: ElementRef) {
111+
super(_elementRef);
112+
}
113+
}

src/material-experimental/mdc-chips/chip-input.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './chip-default-
1313
import {MatChipGrid} from './chip-grid';
1414
import {MatChipTextControl} from './chip-text-control';
1515

16+
1617
/** Represents an input event on a `matChipInput`. */
1718
export interface MatChipInputEvent {
1819
/** The native `<input>` element that the event is being fired for. */

0 commit comments

Comments
 (0)