/**
* @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 {Directionality} from '@angular/cdk/bidi';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
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';
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
import {
CanUpdateErrorState,
CanUpdateErrorStateCtor,
ErrorStateMatcher,
mixinErrorState,
} from '@angular/material/core';
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. */
export class MatChipGridChange {
constructor(
/** Chip grid that emitted the event. */
public source: MatChipGrid,
/** Value of the chip grid when the event was emitted. */
public value: any) { }
}
/**
* Boilerplate for applying mixins to MatChipGrid.
* @docs-private
*/
class MatChipGridBase extends MatChipSet {
constructor(_elementRef: ElementRef,
_changeDetectorRef: ChangeDetectorRef,
_dir: Directionality,
public _defaultErrorStateMatcher: ErrorStateMatcher,
public _parentForm: NgForm,
public _parentFormGroup: FormGroupDirective,
/** @docs-private */
public ngControl: NgControl) {
super(_elementRef, _changeDetectorRef, _dir);
}
}
const _MatChipGridMixinBase: CanUpdateErrorStateCtor & typeof MatChipGridBase =
mixinErrorState(MatChipGridBase);
/**
* An extension of the MatChipSet component used with MatChipRow chips and
* the matChipInputFor directive.
*/
@Component({
moduleId: module.id,
selector: 'mat-chip-grid',
template: '',
styleUrls: ['chips.css'],
inputs: ['tabIndex'],
host: {
'class': 'mat-mdc-chip-set mat-mdc-chip-grid mdc-chip-set',
'[attr.role]': 'role',
'[tabIndex]': '_chips && _chips.length === 0 ? -1 : tabIndex',
// TODO: replace this binding with use of AriaDescriber
'[attr.aria-describedby]': '_ariaDescribedby || null',
'[attr.aria-required]': 'required.toString()',
'[attr.aria-disabled]': 'disabled.toString()',
'[attr.aria-invalid]': 'errorState',
'[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}],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentInit, AfterViewInit,
CanUpdateErrorState, ControlValueAccessor, DoCheck, MatFormFieldControl, OnDestroy {
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
readonly controlType: string = 'mat-chip-grid';
/** Subscription to blur changes in the chips. */
private _chipBlurSubscription: Subscription | null;
/** Subscription to focus changes in the chips. */
private _chipFocusSubscription: Subscription | null;
/** The chip input to add more chips */
protected _chipInput: MatChipTextControl;
/**
* Function when touched. Set as part of ControlValueAccessor implementation.
* @docs-private
*/
_onTouched = () => {};
/**
* Function when changed. Set as part of ControlValueAccessor implementation.
* @docs-private
*/
_onChange: (value: any) => void = () => {};
/** The GridFocusKeyManager which handles focus. */
_keyManager: GridFocusKeyManager;
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
@Input()
get disabled(): boolean { return this.ngControl ? !!this.ngControl.disabled : this._disabled; }
set disabled(value: boolean) {
this._disabled = coerceBooleanProperty(value);
this._syncChipsState();
}
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
get id(): string { return this._chipInput.id; }
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
get empty(): boolean { return this._chipInput.empty && this._chips.length === 0; }
/** The ARIA role applied to the chip grid. */
get role(): string | null { return this.empty ? null : 'grid'; }
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
@Input()
@Input()
get placeholder(): string {
return this._chipInput ? this._chipInput.placeholder : this._placeholder;
}
set placeholder(value: string) {
this._placeholder = value;
this.stateChanges.next();
}
protected _placeholder: string;
/** Whether any chips or the matChipInput inside of this chip-grid has focus. */
get focused(): boolean { return this._chipInput.focused || this._hasFocusedChip(); }
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
@Input()
get required(): boolean { return this._required; }
set required(value: boolean) {
this._required = coerceBooleanProperty(value);
this.stateChanges.next();
}
protected _required: boolean = false;
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
get shouldLabelFloat(): boolean { return !this.empty || this.focused; }
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
@Input()
get value(): any { return this._value; }
set value(value: any) {
this._value = value;
}
protected _value: any;
/** Combined stream of all of the child chips' blur events. */
get chipBlurChanges(): Observable {
return merge(...this._chips.map(chip => chip._onBlur));
}
/** Combined stream of all of the child chips' focus events. */
get chipFocusChanges(): Observable {
return merge(...this._chips.map(chip => chip._onFocus));
}
/** Emits when the chip grid value has been changed by the user. */
@Output() readonly change: EventEmitter =
new EventEmitter();
/**
* Emits whenever the raw value of the chip-grid changes. This is here primarily
* to facilitate the two-way binding for the `value` input.
* @docs-private
*/
@Output() readonly valueChange: EventEmitter = new EventEmitter();
@ContentChildren(MatChipRow, {
// We need to use `descendants: true`, because Ivy will no longer match
// indirect descendants if it's left as false.
descendants: true
})
_chips: QueryList;
constructor(_elementRef: ElementRef,
_changeDetectorRef: ChangeDetectorRef,
@Optional() _dir: Directionality,
@Optional() _parentForm: NgForm,
@Optional() _parentFormGroup: FormGroupDirective,
_defaultErrorStateMatcher: ErrorStateMatcher,
/** @docs-private */
@Optional() @Self() public ngControl: NgControl) {
super(_elementRef, _changeDetectorRef, _dir, _defaultErrorStateMatcher, _parentForm,
_parentFormGroup, ngControl);
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
}
ngAfterContentInit() {
super.ngAfterContentInit();
this._initKeyManager();
this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
// Check to see if we have a destroyed chip and need to refocus
this._updateFocusForDestroyedChips();
this.stateChanges.next();
});
}
ngAfterViewInit() {
super.ngAfterViewInit();
if (!this._chipInput) {
throw Error('mat-chip-grid must be used in combination with matChipInputFor.');
}
}
ngDoCheck() {
if (this.ngControl) {
// We need to re-evaluate this on every change detection cycle, because there are some
// error triggers that we can't subscribe to (e.g. parent form submissions). This means
// that whatever logic is in here has to be super lean or we risk destroying the performance.
this.updateErrorState();
}
}
ngOnDestroy() {
super.ngOnDestroy();
this.stateChanges.complete();
}
/** Associates an HTML input element with this chip grid. */
registerInput(inputElement: MatChipTextControl): void {
this._chipInput = inputElement;
this._setMdcClass('mdc-chip-set--input', true);
}
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
onContainerClick(event: MouseEvent) {
if (!this._originatesFromChip(event) && !this.disabled) {
this.focus();
}
}
/**
* Focuses the first chip in this chip grid, or the associated input when there
* are no eligible chips.
*/
focus(): void {
if (this.disabled || this._chipInput.focused) {
return;
}
if (this._chips.length > 0) {
this._keyManager.setFirstCellActive();
} else {
this._focusInput();
}
this.stateChanges.next();
}
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
setDescribedByIds(ids: string[]) { this._ariaDescribedby = ids.join(' '); }
/**
* Implemented as part of ControlValueAccessor.
* @docs-private
*/
writeValue(value: any): void {
// The user is responsible for creating the child chips, so we just store the value.
this._value = value;
}
/**
* Implemented as part of ControlValueAccessor.
* @docs-private
*/
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
}
/**
* Implemented as part of ControlValueAccessor.
* @docs-private
*/
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
/**
* Implemented as part of ControlValueAccessor.
* @docs-private
*/
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this.stateChanges.next();
}
/** When blurred, mark the field as touched when focus moved outside the chip grid. */
_blur() {
if (this.disabled) {
return;
}
// Check whether the focus moved to chip input.
// If the focus is not moved to chip input, mark the field as touched. If the focus moved
// to chip input, do nothing.
// 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();
}
});
}
/**
* Removes the `tabindex` from the chip grid and resets it back afterwards, allowing the
* user to tab out of it. This prevents the grid from capturing focus and redirecting
* it back to the first chip, creating a focus trap, if it user tries to tab away.
*/
_allowFocusEscape() {
if (this._chipInput.focused) {
return;
}
const previousTabIndex = this.tabIndex;
if (this.tabIndex !== -1) {
this.tabIndex = -1;
setTimeout(() => {
this.tabIndex = previousTabIndex;
this._changeDetectorRef.markForCheck();
});
}
}
/** Handles custom keyboard events. */
_keydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// 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._keyManager.setLastCellActive();
}
event.preventDefault();
} else if (event.keyCode === TAB && target.id !== this._chipInput!.id ) {
this._allowFocusEscape();
} else if (this._originatesFromChip(event)) {
this._keyManager.onKeydown(event);
}
this.stateChanges.next();
}
/** Unsubscribes from all chip events. */
protected _dropSubscriptions() {
super._dropSubscriptions();
if (this._chipBlurSubscription) {
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._chips)
.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 as MatChipRow);
if (this._isValidIndex(chipIndex)) {
this._keyManager.updateActiveCell({row: chipIndex, column: 0});
}
});
}
/** Subscribes to chip blur events. */
private _listenToChipsBlur(): void {
this._chipBlurSubscription = this.chipBlurChanges.subscribe(() => {
this._blur();
this.stateChanges.next();
});
}
/** Emits change event to set the model value. */
private _propagateChanges(fallbackValue?: any): void {
const valueToEmit = this._chips.length ? this._chips.toArray().map(
chip => chip.value) : fallbackValue;
this._value = valueToEmit;
this.change.emit(new MatChipGridChange(this, valueToEmit));
this.valueChange.emit(valueToEmit);
this._onChange(valueToEmit);
this._changeDetectorRef.markForCheck();
}
/** Mark the field as touched */
private _markAsTouched() {
this._onTouched();
this._changeDetectorRef.markForCheck();
this.stateChanges.next();
}
/**
* If the amount of chips changed, we need to focus the next closest chip.
*/
private _updateFocusForDestroyedChips() {
// 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._keyManager.setActiveCell({
row: newChipIndex,
column: this._keyManager.activeColumnIndex
});
} else {
this.focus();
}
}
this._lastDestroyedChipIndex = null;
}
/** Focus input element. */
private _focusInput() {
this._chipInput.focus();
}
/** Returns true if element is an input with no value. */
private _isEmptyInput(element: HTMLElement): boolean {
if (element && element.id === this._chipInput!.id) {
return this._chipInput.empty;
}
return false;
}
}