ipsos-components
Version:
Material Design components for Angular
754 lines (634 loc) • 24.6 kB
text/typescript
/**
* @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 {FocusKeyManager} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {SelectionModel} from '@angular/cdk/collections';
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {startWith} from 'rxjs/operators/startWith';
import {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
DoCheck,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Optional,
Output,
QueryList,
Self,
ViewEncapsulation,
} from '@angular/core';
import {
ControlValueAccessor,
FormGroupDirective,
NgControl,
NgForm
} from '@angular/forms';
import {ErrorStateMatcher, mixinErrorState, CanUpdateErrorState} from '@angular/material/core';
import {MatFormFieldControl} from '@angular/material/form-field';
import {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge';
import {Subscription} from 'rxjs/Subscription';
import {MatChip, MatChipEvent, MatChipSelectionChange} from './chip';
import {MatChipInput} from './chip-input';
// Boilerplate for applying mixins to MatChipList.
/** @docs-private */
export class MatChipListBase {
constructor(public _defaultErrorStateMatcher: ErrorStateMatcher,
public _parentForm: NgForm,
public _parentFormGroup: FormGroupDirective,
public ngControl: NgControl) {}
}
export const _MatChipListMixinBase = mixinErrorState(MatChipListBase);
// Increasing integer for generating unique ids for chip-list components.
let nextUniqueId = 0;
/** Change event object that is emitted when the chip list value has changed. */
export class MatChipListChange {
constructor(
/** Chip list that emitted the event. */
public source: MatChipList,
/** Value of the chip list when the event was emitted. */
public value: any) { }
}
/**
* A material design chips component (named ChipList for it's similarity to the List component).
*/
export class MatChipList extends _MatChipListMixinBase implements MatFormFieldControl<any>,
ControlValueAccessor, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState {
readonly controlType = 'mat-chip-list';
/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
protected _lastDestroyedIndex: number|null = null;
/** Track which chips we're listening to for focus/destruction. */
protected _chipSet: WeakMap<MatChip, boolean> = new WeakMap();
/** Subscription to tabbing out from the chip list. */
private _tabOutSubscription = Subscription.EMPTY;
/** Subscription to changes in the chip list. */
private _changeSubscription: Subscription;
/** Subscription to focus changes in the chips. */
private _chipFocusSubscription: Subscription|null;
/** Subscription to blur changes in the chips. */
private _chipBlurSubscription: Subscription|null;
/** Subscription to selection changes in chips. */
private _chipSelectionSubscription: Subscription|null;
/** Subscription to remove changes in chips. */
private _chipRemoveSubscription: Subscription|null;
/** Whether or not the chip is selectable. */
protected _selectable: boolean = true;
/** Whether the component is in multiple selection mode. */
private _multiple: boolean = false;
/** The chip input to add more chips */
protected _chipInput: MatChipInput;
/** The aria-describedby attribute on the chip list for improved a11y. */
protected _ariaDescribedby: string;
/** Id of the chip list */
protected _id: string;
/** Uid of the chip list */
protected _uid: string = `mat-chip-list-${nextUniqueId++}`;
/** Whether this is required */
protected _required: boolean = false;
/** Whether this is disabled */
protected _disabled: boolean = false;
protected _value: any;
/** Placeholder for the chip list. Alternatively, placeholder can be set on MatChipInput */
protected _placeholder: string;
/** Tab index for the chip list. */
_tabIndex = 0;
/**
* User defined tab index.
* When it is not null, use user defined tab index. Otherwise use _tabIndex
*/
_userTabIndex: number | null = null;
/** The FocusKeyManager which handles focus. */
_keyManager: FocusKeyManager<MatChip>;
/** Function when touched */
_onTouched = () => {};
/** Function when changed */
_onChange: (value: any) => void = () => {};
_selectionModel: SelectionModel<MatChip>;
/** Comparison function to specify which option is displayed. Defaults to object equality. */
private _compareWith = (o1: any, o2: any) => o1 === o2;
/** The array of selected chips inside chip list. */
get selected(): MatChip[] | MatChip {
return this.multiple ? this._selectionModel.selected : this._selectionModel.selected[0];
}
get role(): string|null {
return this.empty ? null : 'listbox';
}
/** An object used to control when error messages are shown. */
errorStateMatcher: ErrorStateMatcher;
/** Whether the user should be allowed to select multiple chips. */
get multiple(): boolean { return this._multiple; }
set multiple(value: boolean) {
this._multiple = coerceBooleanProperty(value);
}
/**
* A function to compare the option values with the selected values. The first argument
* is a value from an option. The second is a value from the selection. A boolean
* should be returned.
*/
get compareWith() { return this._compareWith; }
set compareWith(fn: (o1: any, o2: any) => boolean) {
this._compareWith = fn;
if (this._selectionModel) {
// A different comparator means the selection could change.
this._initializeSelection();
}
}
/** Required for FormFieldControl */
get value() { return this._value; }
set value(newValue: any) {
this.writeValue(newValue);
this._value = newValue;
}
/** Required for FormFieldControl. The ID of the chip list */
set id(value: string) {
this._id = value;
this.stateChanges.next();
}
get id() { return this._id || this._uid; }
/** Required for FormFieldControl. Whether the chip list is required. */
set required(value: any) {
this._required = coerceBooleanProperty(value);
this.stateChanges.next();
}
get required() {
return this._required;
}
/** For FormFieldControl. Use chip input's placholder if there's a chip input */
set placeholder(value: string) {
this._placeholder = value;
this.stateChanges.next();
}
get placeholder() {
return this._chipInput ? this._chipInput.placeholder : this._placeholder;
}
/** Whether any chips or the matChipInput inside of this chip-list has focus. */
get focused(): boolean {
return this.chips.some(chip => chip._hasFocus) ||
(this._chipInput && this._chipInput.focused);
}
/** Whether this chip-list contains no chips and no matChipInput. */
get empty(): boolean {
return (!this._chipInput || this._chipInput.empty) && this.chips.length === 0;
}
get shouldLabelFloat(): boolean {
return !this.empty || this.focused;
}
/** Whether this chip-list is disabled. */
get disabled() { return this.ngControl ? this.ngControl.disabled : this._disabled; }
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
/** Orientation of the chip list. */
ariaOrientation: 'horizontal' | 'vertical' = 'horizontal';
/**
* Whether or not this chip is selectable. When a chip is not selectable,
* its selected state is always ignored.
*/
get selectable(): boolean { return this._selectable; }
set selectable(value: boolean) { this._selectable = coerceBooleanProperty(value); }
set tabIndex(value: number) {
this._userTabIndex = value;
this._tabIndex = value;
}
/** Combined stream of all of the child chips' selection change events. */
get chipSelectionChanges(): Observable<MatChipSelectionChange> {
return merge(...this.chips.map(chip => chip.selectionChange));
}
/** Combined stream of all of the child chips' focus change events. */
get chipFocusChanges(): Observable<MatChipEvent> {
return merge(...this.chips.map(chip => chip._onFocus));
}
/** Combined stream of all of the child chips' blur change events. */
get chipBlurChanges(): Observable<MatChipEvent> {
return merge(...this.chips.map(chip => chip._onBlur));
}
/** Combined stream of all of the child chips' remove change events. */
get chipRemoveChanges(): Observable<MatChipEvent> {
return merge(...this.chips.map(chip => chip.destroy));
}
/** Event emitted when the selected chip list value has been changed by the user. */
change: EventEmitter<MatChipListChange> = new EventEmitter<MatChipListChange>();
/**
* Event that emits whenever the raw value of the chip-list changes. This is here primarily
* to facilitate the two-way binding for the `value` input.
* @docs-private
*/
valueChange = new EventEmitter<any>();
/** The chip components contained within this chip list. */
chips: QueryList<MatChip>;
constructor(protected _elementRef: ElementRef,
private _changeDetectorRef: ChangeDetectorRef,
private _dir: Directionality,
_parentForm: NgForm,
_parentFormGroup: FormGroupDirective,
_defaultErrorStateMatcher: ErrorStateMatcher,
public ngControl: NgControl) {
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
}
ngAfterContentInit(): void {
this._keyManager = new FocusKeyManager<MatChip>(this.chips).withWrap();
// Prevents the chip list from capturing focus and redirecting
// it back to the first chip when the user tabs out.
this._tabOutSubscription = this._keyManager.tabOut.subscribe(() => {
this._tabIndex = -1;
setTimeout(() => this._tabIndex = this._userTabIndex || 0);
});
// When the list changes, re-subscribe
this._changeSubscription = this.chips.changes.pipe(startWith(null)).subscribe(() => {
this._resetChips();
// Reset chips selected/deselected status
this._initializeSelection();
// Check to see if we need to update our tab index
this._updateTabIndex();
// Check to see if we have a destroyed chip and need to refocus
this._updateFocusForDestroyedChips();
});
}
ngOnInit() {
this._selectionModel = new SelectionModel<MatChip>(this.multiple, undefined, false);
this.stateChanges.next();
}
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(): void {
this._tabOutSubscription.unsubscribe();
if (this._changeSubscription) {
this._changeSubscription.unsubscribe();
}
this._dropSubscriptions();
this.stateChanges.complete();
}
/** Associates an HTML input element with this chip list. */
registerInput(inputElement: MatChipInput) {
this._chipInput = inputElement;
}
// Implemented as part of MatFormFieldControl.
setDescribedByIds(ids: string[]) { this._ariaDescribedby = ids.join(' '); }
// Implemented as part of ControlValueAccessor
writeValue(value: any): void {
if (this.chips) {
this._setSelectionByValue(value, false);
}
}
// Implemented as part of ControlValueAccessor
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
}
// Implemented as part of ControlValueAccessor
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
// Implemented as part of ControlValueAccessor
setDisabledState(disabled: boolean): void {
this.disabled = disabled;
this._elementRef.nativeElement.disabled = disabled;
this.stateChanges.next();
}
onContainerClick() {
this.focus();
}
/**
* Focuses the the first non-disabled chip in this chip list, or the associated input when there
* are no eligible chips.
*/
focus() {
// TODO: ARIA says this should focus the first `selected` chip if any are selected.
// Focus on first element if there's no chipInput inside chip-list
if (this._chipInput && this._chipInput.focused) {
// do nothing
} else if (this.chips.length > 0) {
this._keyManager.setFirstItemActive();
this.stateChanges.next();
} else {
this._focusInput();
this.stateChanges.next();
}
}
/** Attempt to focus an input if we have one. */
_focusInput() {
if (this._chipInput) {
this._chipInput.focus();
}
}
/**
* Pass events to the keyboard manager. Available here for tests.
*/
_keydown(event: KeyboardEvent) {
let code = event.keyCode;
let target = event.target as HTMLElement;
let isInputEmpty = this._isInputEmpty(target);
let isRtl = this._dir && this._dir.value == 'rtl';
let isPrevKey = (code === (isRtl ? RIGHT_ARROW : LEFT_ARROW));
let isNextKey = (code === (isRtl ? LEFT_ARROW : RIGHT_ARROW));
let isBackKey = code === BACKSPACE;
// If they are on an empty input and hit backspace, focus the last chip
if (isInputEmpty && isBackKey) {
this._keyManager.setLastItemActive();
event.preventDefault();
return;
}
// If they are on a chip, check for space/left/right, otherwise pass to our key manager (like
// up/down keys)
if (target && target.classList.contains('mat-chip')) {
if (isPrevKey) {
this._keyManager.setPreviousItemActive();
event.preventDefault();
} else if (isNextKey) {
this._keyManager.setNextItemActive();
event.preventDefault();
} else {
this._keyManager.onKeydown(event);
}
}
this.stateChanges.next();
}
/**
* Check the tab index as you should not be allowed to focus an empty list.
*/
protected _updateTabIndex(): void {
// If we have 0 chips, we should not allow keyboard focus
this._tabIndex = this._userTabIndex || (this.chips.length === 0 ? -1 : 0);
}
/**
* Update key manager's active item when chip is deleted.
* If the deleted chip is the last chip in chip list, focus the new last chip.
* Otherwise focus the next chip in the list.
* Save `_lastDestroyedIndex` so we can set the correct focus.
*/
protected _updateKeyManager(chip: MatChip) {
let chipIndex: number = this.chips.toArray().indexOf(chip);
if (this._isValidIndex(chipIndex)) {
if (chip._hasFocus) {
// Check whether the chip is not the last item
if (chipIndex < this.chips.length - 1) {
this._keyManager.setActiveItem(chipIndex);
} else if (chipIndex - 1 >= 0) {
this._keyManager.setActiveItem(chipIndex - 1);
}
}
if (this._keyManager.activeItemIndex === chipIndex) {
this._lastDestroyedIndex = chipIndex;
}
}
}
/**
* Checks to see if a focus chip was recently destroyed so that we can refocus the next closest
* one.
*/
protected _updateFocusForDestroyedChips() {
let chipsArray = this.chips;
if (this._lastDestroyedIndex != null && chipsArray.length > 0) {
// Check whether the destroyed chip was the last item
const newFocusIndex = Math.min(this._lastDestroyedIndex, chipsArray.length - 1);
this._keyManager.setActiveItem(newFocusIndex);
let focusChip = this._keyManager.activeItem;
// Focus the chip
if (focusChip) {
focusChip.focus();
}
}
// Reset our destroyed index
this._lastDestroyedIndex = null;
}
/**
* 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.
*/
private _isValidIndex(index: number): boolean {
return index >= 0 && index < this.chips.length;
}
private _isInputEmpty(element: HTMLElement): boolean {
if (element && element.nodeName.toLowerCase() === 'input') {
let input = element as HTMLInputElement;
return !input.value;
}
return false;
}
_setSelectionByValue(value: any, isUserInput: boolean = true) {
this._clearSelection();
this.chips.forEach(chip => chip.deselect());
if (Array.isArray(value)) {
value.forEach(currentValue => this._selectValue(currentValue, isUserInput));
this._sortValues();
} else {
const correspondingChip = this._selectValue(value, isUserInput);
// Shift focus to the active item. Note that we shouldn't do this in multiple
// mode, because we don't know what chip the user interacted with last.
if (correspondingChip) {
const correspondingChipIndex = this.chips.toArray().indexOf(correspondingChip);
if (isUserInput) {
this._keyManager.setActiveItem(correspondingChipIndex);
} else {
this._keyManager.updateActiveItemIndex(correspondingChipIndex);
}
}
}
}
/**
* Finds and selects the chip based on its value.
* @returns Chip that has the corresponding value.
*/
private _selectValue(value: any, isUserInput: boolean = true): MatChip | undefined {
const correspondingChip = this.chips.find(chip => {
return chip.value != null && this._compareWith(chip.value, value);
});
if (correspondingChip) {
isUserInput ? correspondingChip.selectViaInteraction() : correspondingChip.select();
this._selectionModel.select(correspondingChip);
}
return correspondingChip;
}
private _initializeSelection(): void {
// Defer setting the value in order to avoid the "Expression
// has changed after it was checked" errors from Angular.
Promise.resolve().then(() => {
if (this.ngControl || this._value) {
this._setSelectionByValue(this.ngControl ? this.ngControl.value : this._value, false);
this.stateChanges.next();
}
});
}
/**
* Deselects every chip in the list.
* @param skip Chip that should not be deselected.
*/
private _clearSelection(skip?: MatChip): void {
this._selectionModel.clear();
this.chips.forEach(chip => {
if (chip !== skip) {
chip.deselect();
}
});
this.stateChanges.next();
}
/**
* Sorts the model values, ensuring that they keep the same
* order that they have in the panel.
*/
private _sortValues(): void {
if (this._multiple) {
this._selectionModel.clear();
this.chips.forEach(chip => {
if (chip.selected) {
this._selectionModel.select(chip);
}
});
this.stateChanges.next();
}
}
/** Emits change event to set the model value. */
private _propagateChanges(fallbackValue?: any): void {
let valueToEmit: any = null;
if (Array.isArray(this.selected)) {
valueToEmit = this.selected.map(chip => chip.value);
} else {
valueToEmit = this.selected ? this.selected.value : fallbackValue;
}
this._value = valueToEmit;
this.change.emit(new MatChipListChange(this, valueToEmit));
this.valueChange.emit(valueToEmit);
this._onChange(valueToEmit);
this._changeDetectorRef.markForCheck();
}
/** When blurred, mark the field as touched when focus moved outside the chip list. */
_blur() {
if (!this.disabled) {
if (this._chipInput) {
// If there's a chip input, we should 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._markAsTouched();
}
});
} else {
// If there's no chip input, then mark the field as touched.
this._markAsTouched();
}
}
}
/** Mark the field as touched */
_markAsTouched() {
this._onTouched();
this._changeDetectorRef.markForCheck();
this.stateChanges.next();
}
private _resetChips() {
this._dropSubscriptions();
this._listenToChipsFocus();
this._listenToChipsSelection();
this._listenToChipsRemoved();
}
private _dropSubscriptions() {
if (this._chipFocusSubscription) {
this._chipFocusSubscription.unsubscribe();
this._chipFocusSubscription = null;
}
if (this._chipBlurSubscription) {
this._chipBlurSubscription.unsubscribe();
this._chipBlurSubscription = null;
}
if (this._chipSelectionSubscription) {
this._chipSelectionSubscription.unsubscribe();
this._chipSelectionSubscription = null;
}
}
/** Listens to user-generated selection events on each chip. */
private _listenToChipsSelection(): void {
this._chipSelectionSubscription = this.chipSelectionChanges.subscribe(event => {
event.source.selected
? this._selectionModel.select(event.source)
: this._selectionModel.deselect(event.source);
// For single selection chip list, make sure the deselected value is unselected.
if (!this.multiple) {
this.chips.forEach(chip => {
if (!this._selectionModel.isSelected(chip) && chip.selected) {
chip.deselect();
}
});
}
if (event.isUserInput) {
this._propagateChanges();
}
});
}
/** Listens to user-generated selection events on each chip. */
private _listenToChipsFocus(): void {
this._chipFocusSubscription = this.chipFocusChanges.subscribe(event => {
let chipIndex: number = this.chips.toArray().indexOf(event.chip);
if (this._isValidIndex(chipIndex)) {
this._keyManager.updateActiveItemIndex(chipIndex);
}
this.stateChanges.next();
});
this._chipBlurSubscription = this.chipBlurChanges.subscribe(_ => {
this._blur();
this.stateChanges.next();
});
}
private _listenToChipsRemoved(): void {
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event) => {
this._updateKeyManager(event.chip);
});
}
}