Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(material-experimental/chips) Add grid keyboard shortcuts #16384

Merged
merged 1 commit into from
Jun 28, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

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


/** Default options, for the chips module, that can be overridden. */
export interface MatChipsDefaultOptions {
/** The list of key codes that will trigger a chipEnd event. */
Expand Down
106 changes: 98 additions & 8 deletions src/material-experimental/mdc-chips/chip-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directionality} from '@angular/cdk/bidi';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {BACKSPACE} from '@angular/cdk/keycodes';
import {BACKSPACE, TAB} from '@angular/cdk/keycodes';
import {
AfterContentInit,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
DoCheck,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Optional,
Output,
QueryList,
Self,
ViewEncapsulation
} from '@angular/core';
Expand All @@ -35,9 +38,10 @@ import {MatFormFieldControl} from '@angular/material/form-field';
import {MatChipTextControl} from './chip-text-control';
import {merge, Observable, Subscription} from 'rxjs';
import {startWith, takeUntil} from 'rxjs/operators';

import {MatChipEvent} from './chip';
import {MatChipRow} from './chip-row';
import {MatChipSet} from './chip-set';
import {GridFocusKeyManager} from './grid-focus-key-manager';


/** Change event object that is emitted when the chip grid value has changed. */
Expand Down Expand Up @@ -76,10 +80,11 @@ const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase =
selector: 'mat-chip-grid',
template: '<ng-content></ng-content>',
styleUrls: ['chips.css'],
inputs: ['tabIndex'],
host: {
'class': 'mat-mdc-chip-set mat-mdc-chip-grid mdc-chip-set',
'role': 'grid',
'[tabIndex]': 'empty ? -1 : 0',
'[tabIndex]': 'tabIndex',
// TODO: replace this binding with use of AriaDescriber
'[attr.aria-describedby]': '_ariaDescribedby || null',
'[attr.aria-required]': 'required.toString()',
Expand All @@ -88,6 +93,9 @@ const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase =
'[class.mat-mdc-chip-list-disabled]': 'disabled',
'[class.mat-mdc-chip-list-invalid]': 'errorState',
'[class.mat-mdc-chip-list-required]': 'required',
'(focus)': 'focus()',
'(blur)': '_blur()',
'(keydown)': '_keydown($event)',
'[id]': '_uid',
},
providers: [{provide: MatFormFieldControl, useExisting: MatChipGrid}],
Expand All @@ -105,6 +113,9 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
/** Subscription to blur changes in the chips. */
private _chipBlurSubscription: Subscription | null;

/** Subscription to focus changes in the chips. */
private _chipFocusSubscription: Subscription | null;
vanessanschmitt marked this conversation as resolved.
Show resolved Hide resolved

/** The chip input to add more chips */
protected _chipInput: MatChipTextControl;

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

/** The GridFocusKeyManager which handles focus. */
_keyManager: GridFocusKeyManager;

/**
* Implemented as part of MatFormFieldControl.
* @docs-private
Expand Down Expand Up @@ -187,6 +201,11 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
return merge(...this._chips.map(chip => chip._onBlur));
}

/** Combined stream of all of the child chips' focus events. */
get chipFocusChanges(): Observable<MatChipEvent> {
return merge(...this._chips.map(chip => chip._onFocus));
}

/** Emits when the chip grid value has been changed by the user. */
@Output() readonly change: EventEmitter<MatChipGridChange> =
new EventEmitter<MatChipGridChange>();
Expand All @@ -198,8 +217,16 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
*/
@Output() readonly valueChange: EventEmitter<any> = new EventEmitter<any>();

@ContentChildren(MatChipRow, {
// We need to use `descendants: true`, because Ivy will no longer match
// indirect descendants if it's left as false.
descendants: true
})
_rowChips: QueryList<MatChipRow>;

constructor(_elementRef: ElementRef,
_changeDetectorRef: ChangeDetectorRef,
@Optional() private _dir: Directionality,
@Optional() _parentForm: NgForm,
@Optional() _parentFormGroup: FormGroupDirective,
_defaultErrorStateMatcher: ErrorStateMatcher,
Expand All @@ -214,7 +241,11 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn

ngAfterContentInit() {
super.ngAfterContentInit();
this._initKeyManager();

this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
this._updateTabIndex();

// Check to see if we have a destroyed chip and need to refocus
this._updateFocusForDestroyedChips();

Expand Down Expand Up @@ -269,7 +300,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
}

if (this._chips.length > 0) {
this._chips.toArray()[0].focus();
this._keyManager.setFirstCellActive();
} else {
this._focusInput();
}
Expand Down Expand Up @@ -320,6 +351,7 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
// Timeout is needed to wait for the focus() event trigger on chip input.
setTimeout(() => {
if (!this.focused) {
this._keyManager.setActiveCell({row: -1, column: -1});
this._propagateChanges();
this._markAsTouched();
}
Expand All @@ -332,7 +364,18 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
* it back to the first chip, creating a focus trap, if it user tries to tab away.
*/
_allowFocusEscape() {
// TODO
if (this._chipInput.focused) {
return;
}

if (this.tabIndex !== -1) {
this.tabIndex = -1;

setTimeout(() => {
this.tabIndex = 0;
this._changeDetectorRef.markForCheck();
});
}
}

/** Handles custom keyboard events. */
Expand All @@ -342,11 +385,15 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
// If they are on an empty input and hit backspace, focus the last chip
if (event.keyCode === BACKSPACE && this._isEmptyInput(target)) {
if (this._chips.length) {
this._chips.toArray()[this._chips.length - 1].focus();
this._keyManager.setLastCellActive();
}
event.preventDefault();
} else if (event.keyCode === TAB) {
this._allowFocusEscape();
} else {
this._keyManager.onKeydown(event);
}
this.stateChanges.next();
this.stateChanges.next();
}

/** Unsubscribes from all chip events. */
Expand All @@ -356,14 +403,43 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
this._chipBlurSubscription.unsubscribe();
this._chipBlurSubscription = null;
}

if (this._chipFocusSubscription) {
this._chipFocusSubscription.unsubscribe();
this._chipFocusSubscription = null;
}
}

/** Subscribes to events on the child chips. */
protected _subscribeToChipEvents() {
super._subscribeToChipEvents();
this._listenToChipsFocus();
this._listenToChipsBlur();
}

/** Initializes the key manager to manage focus. */
private _initKeyManager() {
this._keyManager = new GridFocusKeyManager(this._rowChips)
.withDirectionality(this._dir ? this._dir.value : 'ltr');

if (this._dir) {
this._dir.change
.pipe(takeUntil(this._destroyed))
.subscribe(dir => this._keyManager.withDirectionality(dir));
}
}

/** Subscribes to chip focus events. */
private _listenToChipsFocus(): void {
this._chipFocusSubscription = this.chipFocusChanges.subscribe((event: MatChipEvent) => {
let chipIndex: number = this._chips.toArray().indexOf(event.chip);

if (this._isValidIndex(chipIndex)) {
this._keyManager.updateActiveCell({row: chipIndex, column: 0});
}
});
}

/** Subscribes to chip blur events. */
private _listenToChipsBlur(): void {
this._chipBlurSubscription = this.chipBlurChanges.subscribe(() => {
Expand Down Expand Up @@ -409,17 +485,23 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
* If the amount of chips changed, we need to focus the next closest chip.
*/
private _updateFocusForDestroyedChips() {
// Wait for chips to be updated in keyManager
setTimeout(() => {
// Move focus to the closest chip. If no other chips remain, focus the chip-grid itself.
if (this._lastDestroyedChipIndex != null) {
if (this._chips.length) {
const newChipIndex = Math.min(this._lastDestroyedChipIndex, this._chips.length - 1);
this._chips.toArray()[newChipIndex].focus();
this._keyManager.setActiveCell({
row: newChipIndex,
column: this._keyManager.activeColumnIndex
});
} else {
this.focus();
}
}

this._lastDestroyedChipIndex = null;
});
}

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

return false;
}

/**
* Check the tab index as you should not be allowed to focus an empty grid.
*/
protected _updateTabIndex(): void {
// If we have 0 chips, we should not allow keyboard focus
this.tabIndex = this._chips.length === 0 ? -1 : 0;
}
}
113 changes: 113 additions & 0 deletions src/material-experimental/mdc-chips/chip-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {
ChangeDetectorRef,
Directive,
ElementRef,
} from '@angular/core';
import {
CanDisable,
CanDisableCtor,
HasTabIndex,
HasTabIndexCtor,
mixinDisabled,
mixinTabIndex,
} from '@angular/material/core';
import {Subject} from 'rxjs';


/**
* Directive to add CSS classes to chip leading icon.
* @docs-private
*/
@Directive({
selector: 'mat-chip-avatar, [matChipAvatar]',
host: {
'class': 'mat-mdc-chip-avatar mdc-chip__icon mdc-chip__icon--leading',
'role': 'img'
}
})
export class MatChipAvatar {
constructor(private _changeDetectorRef: ChangeDetectorRef,
private _elementRef: ElementRef) {}

/** Sets whether the given CSS class should be applied to the leading icon. */
setClass(cssClass: string, active: boolean) {
const element = this._elementRef.nativeElement;
active ? element.addClass(cssClass) : element.removeClass(cssClass);
mmalerba marked this conversation as resolved.
Show resolved Hide resolved
this._changeDetectorRef.markForCheck();
}
}

/**
* Directive to add CSS classes to and configure attributes for chip trailing icon.
* @docs-private
*/
@Directive({
selector: 'mat-chip-trailing-icon, [matChipTrailingIcon]',
host: {
'class': 'mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing',
'tabindex': '-1',
'aria-hidden': 'true',
}
})
export class MatChipTrailingIcon {
}

/**
* Boilerplate for applying mixins to MatChipRemove.
* @docs-private
*/
class MatChipRemoveBase extends MatChipTrailingIcon {
constructor(public _elementRef: ElementRef) {
super();
}
}

const _MatChipRemoveMixinBase:
CanDisableCtor &
HasTabIndexCtor &
typeof MatChipRemoveBase =
mixinTabIndex(mixinDisabled(MatChipRemoveBase));

/**
* Directive to remove the parent chip when the trailing icon is clicked or
* when the ENTER key is pressed on it.
*
* Recommended for use with the Material Design "cancel" icon
* available at https://material.io/icons/#ic_cancel.
*
* Example:
*
* `<mat-chip>
* <mat-icon matChipRemove>cancel</mat-icon>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed this example (copied from the old implementation) is wrong. The element should be a button.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mat-icon element should be a button? This example matches the demo, which seems to work well: https://github.com/angular/components/blob/master/src/dev-app/chips/chips-demo.html#L47

I did put 'role': 'button' on the host element for matChipRemove:
https://github.com/angular/components/pull/16384/files#diff-350c9b188f681cf2a48614ce9b6a4cffR100

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the role also works, it's just a style thing. We typically tell people to do

<button mat-icon-button aria-label="...">
  <mat-icon>...</mat-icon>
</button>

rather than putting click handlers on icons directly; it's mostly about setting a good example

Copy link
Collaborator Author

@vanessanschmitt vanessanschmitt Jun 28, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense! The mdc classes right now don't play nicely with mat-icon-button. Can I fix this in a follow up PR? @jelbourn

* </mat-chip>`
*/
@Directive({
selector: '[matChipRemove]',
inputs: ['disabled', 'tabIndex'],
host: {
'class': 'mat-mdc-chip-trailing-icon mdc-chip__icon mdc-chip__icon--trailing',
'[tabIndex]': 'tabIndex',
'role': 'button',
'(click)': 'interaction.next($event)',
'(keydown)': 'interaction.next($event)',
}
})
export class MatChipRemove extends _MatChipRemoveMixinBase implements CanDisable, HasTabIndex {
/**
* Emits when the user interacts with the icon.
* @docs-private
*/
interaction: Subject<MouseEvent | KeyboardEvent> = new Subject<MouseEvent | KeyboardEvent>();

constructor(_elementRef: ElementRef) {
super(_elementRef);
}
}
1 change: 1 addition & 0 deletions src/material-experimental/mdc-chips/chip-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './chip-default-
import {MatChipGrid} from './chip-grid';
import {MatChipTextControl} from './chip-text-control';


/** Represents an input event on a `matChipInput`. */
export interface MatChipInputEvent {
/** The native `<input>` element that the event is being fired for. */
Expand Down
Loading