/**
* @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 {
AfterContentInit,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
Input,
OnDestroy,
Optional,
QueryList,
ViewEncapsulation
} from '@angular/core';
import {HasTabIndex, HasTabIndexCtor, mixinTabIndex} from '@angular/material/core';
import {MDCChipSetAdapter, MDCChipSetFoundation} from '@material/chips';
import {merge, Observable, Subject, Subscription} from 'rxjs';
import {startWith, takeUntil} from 'rxjs/operators';
import {MatChip, MatChipEvent} from './chip';
let uid = 0;
/**
* Boilerplate for applying mixins to MatChipSet.
* @docs-private
*/
class MatChipSetBase {
disabled!: boolean;
constructor(_elementRef: ElementRef) {}
}
const _MatChipSetMixinBase: HasTabIndexCtor & typeof MatChipSetBase =
mixinTabIndex(MatChipSetBase);
/**
* Basic container component for the MatChip component.
*
* Extended by MatChipListbox and MatChipGrid for different interaction patterns.
*/
@Component({
moduleId: module.id,
selector: 'mat-chip-set',
template: '',
styleUrls: ['chips.css'],
host: {
'class': 'mat-mdc-chip-set mdc-chip-set',
'[attr.role]': 'role',
// TODO: replace this binding with use of AriaDescriber
'[attr.aria-describedby]': '_ariaDescribedby || null',
'[id]': '_uid',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit, AfterViewInit,
HasTabIndex, OnDestroy {
/** Subscription to remove changes in chips. */
private _chipRemoveSubscription: Subscription | null;
/** Subscription to destroyed events in chips. */
private _chipDestroyedSubscription: Subscription | null;
/** Subscription to chip interactions. */
private _chipInteractionSubscription: Subscription | null;
/**
* When a chip is destroyed, we store the index of the destroyed chip until the chips
* query list notifies about the update. This is necessary because we cannot determine an
* appropriate chip that should receive focus until the array of chips updated completely.
*/
protected _lastDestroyedChipIndex: number | null = null;
/** The MDC foundation containing business logic for MDC chip-set. */
protected _chipSetFoundation: MDCChipSetFoundation;
/** Subject that emits when the component has been destroyed. */
protected _destroyed = new Subject();
/**
* Implementation of the MDC chip-set adapter interface.
* These methods are called by the chip set foundation.
*/
protected _chipSetAdapter: MDCChipSetAdapter = {
hasClass: (className) => this._hasMdcClass(className),
// No-op. We keep track of chips via ContentChildren, which will be updated when a chip is
// removed.
removeChipAtIndex: () => {},
// No-op for base chip set. MatChipListbox overrides the adapter to provide this method.
selectChipAtIndex: () => {},
getIndexOfChipById: (id: string) => this._chips.toArray().findIndex(chip => chip.id === id),
focusChipPrimaryActionAtIndex: () => {},
focusChipTrailingActionAtIndex: () => {},
removeFocusFromChipAtIndex: () => {},
isRTL: () => !!this._dir && this._dir.value === 'rtl',
getChipListCount: () => this._chips.length,
};
/** The aria-describedby attribute on the chip list for improved a11y. */
_ariaDescribedby: string;
/** Uid of the chip set */
_uid: string = `mat-mdc-chip-set-${uid++}`;
/**
* Map from class to whether the class is enabled.
* Enabled classes are set on the MDC chip-set div.
*/
_mdcClasses: {[key: string]: boolean} = {};
/** Whether the chip set is disabled. */
@Input()
get disabled(): boolean { return this._disabled; }
set disabled(value: boolean) {
this._disabled = coerceBooleanProperty(value);
this._syncChipsState();
}
protected _disabled: boolean = false;
/** Whether the chip list contains chips or not. */
get empty(): boolean { return this._chips.length === 0; }
/** The ARIA role applied to the chip set. */
get role(): string | null { return this.empty ? null : 'presentation'; }
/** Whether any of the chips inside of this chip-set has focus. */
get focused(): boolean { return this._hasFocusedChip(); }
/** Combined stream of all of the child chips' remove events. */
get chipRemoveChanges(): Observable {
return merge(...this._chips.map(chip => chip.removed));
}
/** Combined stream of all of the child chips' remove events. */
get chipDestroyedChanges(): Observable {
return merge(...this._chips.map(chip => chip.destroyed));
}
/** Combined stream of all of the child chips' interaction events. */
get chipInteractionChanges(): Observable {
return merge(...this._chips.map(chip => chip.interaction));
}
/** The chips that are part of this chip set. */
@ContentChildren(MatChip) _chips: QueryList;
constructor(protected _elementRef: ElementRef,
protected _changeDetectorRef: ChangeDetectorRef,
@Optional() protected _dir: Directionality) {
super(_elementRef);
this._chipSetFoundation = new MDCChipSetFoundation(this._chipSetAdapter);
}
ngAfterViewInit() {
this._chipSetFoundation.init();
}
ngAfterContentInit() {
this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
if (this.disabled) {
// Since this happens after the content has been
// checked, we need to defer it to the next tick.
Promise.resolve().then(() => {
this._syncChipsState();
});
}
this._resetChips();
});
}
ngOnDestroy() {
this._dropSubscriptions();
this._destroyed.next();
this._destroyed.complete();
this._chipSetFoundation.destroy();
}
/** Checks whether any of the chips is focused. */
protected _hasFocusedChip() {
return this._chips.some(chip => chip._hasFocus);
}
/** Syncs the chip-set's state with the individual chips. */
protected _syncChipsState() {
if (this._chips) {
this._chips.forEach(chip => {
chip.disabled = this._disabled;
chip._changeDetectorRef.markForCheck();
});
}
}
/** Sets whether the given CSS class should be applied to the MDC chip. */
protected _setMdcClass(cssClass: string, active: boolean) {
const classes = this._elementRef.nativeElement.classList;
active ? classes.add(cssClass) : classes.remove(cssClass);
this._changeDetectorRef.markForCheck();
}
/** Adapter method that returns true if the chip set has the given MDC class. */
protected _hasMdcClass(className: string) {
return this._elementRef.nativeElement.classList.contains(className);
}
/** Updates subscriptions to chip events. */
private _resetChips() {
this._dropSubscriptions();
this._subscribeToChipEvents();
}
/** Subscribes to events on the child chips. */
protected _subscribeToChipEvents() {
this._listenToChipsRemove();
this._listenToChipsDestroyed();
this._listenToChipsInteraction();
}
/** Subscribes to chip removal events. */
private _listenToChipsRemove() {
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event: MatChipEvent) => {
this._chipSetFoundation.handleChipRemoval(event.chip.id);
});
}
/** Subscribes to chip destroyed events. */
private _listenToChipsDestroyed() {
this._chipDestroyedSubscription = this.chipDestroyedChanges.subscribe((event: MatChipEvent) => {
const chip = event.chip;
const chipIndex: number = this._chips.toArray().indexOf(event.chip);
// In case the chip that will be removed is currently focused, we temporarily store
// the index in order to be able to determine an appropriate sibling chip that will
// receive focus.
if (this._isValidIndex(chipIndex) && chip._hasFocus) {
this._lastDestroyedChipIndex = chipIndex;
}
});
}
/** Subscribes to chip interaction events. */
private _listenToChipsInteraction() {
this._chipInteractionSubscription = this.chipInteractionChanges.subscribe((id: string) => {
this._chipSetFoundation.handleChipInteraction(id);
});
}
/** Unsubscribes from all chip events. */
protected _dropSubscriptions() {
if (this._chipRemoveSubscription) {
this._chipRemoveSubscription.unsubscribe();
this._chipRemoveSubscription = null;
}
if (this._chipInteractionSubscription) {
this._chipInteractionSubscription.unsubscribe();
this._chipInteractionSubscription = null;
}
if (this._chipDestroyedSubscription) {
this._chipDestroyedSubscription.unsubscribe();
this._chipDestroyedSubscription = null;
}
}
/** Dummy method for subclasses to override. Base chip set cannot be focused. */
focus() {}
/**
* Utility to ensure all indexes are valid.
*
* @param index The index to be checked.
* @returns True if the index is valid for our list of chips.
*/
protected _isValidIndex(index: number): boolean {
return index >= 0 && index < this._chips.length;
}
/** Checks whether an event comes from inside a chip element. */
protected _originatesFromChip(event: Event): boolean {
let currentElement = event.target as HTMLElement | null;
while (currentElement && currentElement !== this._elementRef.nativeElement) {
if (currentElement.classList.contains('mdc-chip')) {
return true;
}
currentElement = currentElement.parentElement;
}
return false;
}
}