/** * @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, END, HOME} from '@angular/cdk/keycodes'; 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 { CanUpdateErrorState, CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState, } from '@angular/material/core'; import {MatFormFieldControl} from '@angular/material/form-field'; import {merge, Observable, Subject, Subscription} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; import {MatChip, MatChipEvent, MatChipSelectionChange} from './chip'; import {MatChipTextControl} from './chip-text-control'; // Boilerplate for applying mixins to MatChipList. /** @docs-private */ class MatChipListBase { constructor(public _defaultErrorStateMatcher: ErrorStateMatcher, public _parentForm: NgForm, public _parentFormGroup: FormGroupDirective, /** @docs-private */ public ngControl: NgControl) {} } const _MatChipListMixinBase: CanUpdateErrorStateCtor & typeof MatChipListBase = 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 its similarity to the List component). */ @Component({ moduleId: module.id, selector: 'mat-chip-list', template: `
`, exportAs: 'matChipList', host: { '[attr.tabindex]': 'disabled ? null : _tabIndex', '[attr.aria-describedby]': '_ariaDescribedby || null', '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', '[attr.aria-invalid]': 'errorState', '[attr.aria-multiselectable]': 'multiple', '[attr.role]': 'role', '[class.mat-chip-list-disabled]': 'disabled', '[class.mat-chip-list-invalid]': 'errorState', '[class.mat-chip-list-required]': 'required', '[attr.aria-orientation]': 'ariaOrientation', 'class': 'mat-chip-list', '(focus)': 'focus()', '(blur)': '_blur()', '(keydown)': '_keydown($event)', '[id]': '_uid', }, providers: [{provide: MatFormFieldControl, useExisting: MatChipList}], styleUrls: ['chips.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) export class MatChipList extends _MatChipListMixinBase implements MatFormFieldControl, ControlValueAccessor, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState { /** * Implemented as part of MatFormFieldControl. * @docs-private */ readonly controlType: string = 'mat-chip-list'; /** * 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. */ private _lastDestroyedChipIndex: number | null = null; /** Subject that emits when the component has been destroyed. */ private _destroyed = new Subject(); /** 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; /** The chip input to add more chips */ protected _chipInput: MatChipTextControl; /** Uid of the chip list */ _uid: string = `mat-chip-list-${nextUniqueId++}`; /** The aria-describedby attribute on the chip list for improved a11y. */ _ariaDescribedby: 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; /** Function when touched */ _onTouched = () => {}; /** Function when changed */ _onChange: (value: any) => void = () => {}; _selectionModel: SelectionModel; /** The array of selected chips inside chip list. */ get selected(): MatChip[] | MatChip { return this.multiple ? this._selectionModel.selected : this._selectionModel.selected[0]; } /** The ARIA role applied to the chip list. */ get role(): string | null { return this.empty ? null : 'listbox'; } /** An object used to control when error messages are shown. */ @Input() errorStateMatcher: ErrorStateMatcher; /** Whether the user should be allowed to select multiple chips. */ @Input() get multiple(): boolean { return this._multiple; } set multiple(value: boolean) { this._multiple = coerceBooleanProperty(value); this._syncChipsState(); } private _multiple: boolean = false; /** * 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. */ @Input() get compareWith(): (o1: any, o2: any) => boolean { 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(); } } private _compareWith = (o1: any, o2: any) => o1 === o2; /** * Implemented as part of MatFormFieldControl. * @docs-private */ @Input() get value(): any { return this._value; } set value(value: any) { this.writeValue(value); this._value = value; } protected _value: any; /** * Implemented as part of MatFormFieldControl. * @docs-private */ get id(): string { return this._chipInput ? this._chipInput.id : this._uid; } /** * 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 */ @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-list has focus. */ get focused(): boolean { return (this._chipInput && this._chipInput.focused) || this._hasFocusedChip(); } /** * Implemented as part of MatFormFieldControl. * @docs-private */ get empty(): boolean { return (!this._chipInput || this._chipInput.empty) && this.chips.length === 0; } /** * Implemented as part of MatFormFieldControl. * @docs-private */ get shouldLabelFloat(): boolean { return !this.empty || this.focused; } /** * 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(); } protected _disabled: boolean = false; /** Orientation of the chip list. */ @Input('aria-orientation') ariaOrientation: 'horizontal' | 'vertical' = 'horizontal'; /** * Whether or not this chip list is selectable. When a chip list is not selectable, * the selected states for all the chips inside the chip list are always ignored. */ @Input() get selectable(): boolean { return this._selectable; } set selectable(value: boolean) { this._selectable = coerceBooleanProperty(value); if (this.chips) { this.chips.forEach(chip => chip.chipListSelectable = this._selectable); } } protected _selectable: boolean = true; @Input() set tabIndex(value: number) { this._userTabIndex = value; this._tabIndex = value; } /** Combined stream of all of the child chips' selection change events. */ get chipSelectionChanges(): Observable { return merge(...this.chips.map(chip => chip.selectionChange)); } /** Combined stream of all of the child chips' focus change events. */ get chipFocusChanges(): Observable { return merge(...this.chips.map(chip => chip._onFocus)); } /** Combined stream of all of the child chips' blur change events. */ get chipBlurChanges(): Observable { return merge(...this.chips.map(chip => chip._onBlur)); } /** Combined stream of all of the child chips' remove change events. */ get chipRemoveChanges(): Observable { return merge(...this.chips.map(chip => chip.destroyed)); } /** Event emitted when the selected chip list value has been changed by the user. */ @Output() readonly change: EventEmitter = new EventEmitter(); /** * 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 */ @Output() readonly valueChange: EventEmitter = new EventEmitter(); /** The chip components contained within this chip list. */ @ContentChildren(MatChip, { // 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(protected _elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, @Optional() private _dir: Directionality, @Optional() _parentForm: NgForm, @Optional() _parentFormGroup: FormGroupDirective, _defaultErrorStateMatcher: ErrorStateMatcher, /** @docs-private */ @Optional() @Self() public ngControl: NgControl) { super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); if (this.ngControl) { this.ngControl.valueAccessor = this; } } ngAfterContentInit() { this._keyManager = new FocusKeyManager(this.chips) .withWrap() .withVerticalOrientation() .withHorizontalOrientation(this._dir ? this._dir.value : 'ltr'); if (this._dir) { this._dir.change .pipe(takeUntil(this._destroyed)) .subscribe(dir => this._keyManager.withHorizontalOrientation(dir)); } this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => { this._allowFocusEscape(); }); // When the list changes, re-subscribe 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(); // 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(); this.stateChanges.next(); }); } ngOnInit() { this._selectionModel = new SelectionModel(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() { this._destroyed.next(); this._destroyed.complete(); this.stateChanges.complete(); this._dropSubscriptions(); } /** Associates an HTML input element with this chip list. */ registerInput(inputElement: MatChipTextControl): void { this._chipInput = inputElement; } /** * Implemented as part of MatFormFieldControl. * @docs-private */ 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(isDisabled: boolean): void { this.disabled = isDisabled; this.stateChanges.next(); } /** * Implemented as part of MatFormFieldControl. * @docs-private */ onContainerClick(event: MouseEvent) { if (!this._originatesFromChip(event)) { this.focus(); } } /** * Focuses the first non-disabled chip in this chip list, or the associated input when there * are no eligible chips. */ focus(options?: FocusOptions): void { if (this.disabled) { return; } // 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(options); this.stateChanges.next(); } } /** Attempt to focus an input if we have one. */ _focusInput(options?: FocusOptions) { if (this._chipInput) { this._chipInput.focus(options); } } /** * Pass events to the keyboard manager. Available here for tests. */ _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._isInputEmpty(target)) { this._keyManager.setLastItemActive(); event.preventDefault(); } else if (target && target.classList.contains('mat-chip')) { if (event.keyCode === HOME) { this._keyManager.setFirstItemActive(); event.preventDefault(); } else if (event.keyCode === END) { this._keyManager.setLastItemActive(); 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); } /** * If the amount of chips changed, we need to update the * key manager state and focus the next closest chip. */ protected _updateFocusForDestroyedChips() { // Move focus to the closest chip. If no other chips remain, focus the chip-list itself. if (this._lastDestroyedChipIndex != null) { if (this.chips.length) { const newChipIndex = Math.min(this._lastDestroyedChipIndex, this.chips.length - 1); this._keyManager.setActiveItem(newChipIndex); } else { this.focus(); } } this._lastDestroyedChipIndex = 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) { if (isUserInput) { this._keyManager.setActiveItem(correspondingChip); } } } } /** * 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._hasFocusedChip()) { this._keyManager.setActiveItem(-1); } 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(); } /** * Removes the `tabindex` from the chip list and resets it back afterwards, allowing the * user to tab out of it. This prevents the list 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._tabIndex !== -1) { this._tabIndex = -1; setTimeout(() => { this._tabIndex = this._userTabIndex || 0; this._changeDetectorRef.markForCheck(); }); } } 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; } if (this._chipRemoveSubscription) { this._chipRemoveSubscription.unsubscribe(); this._chipRemoveSubscription = 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 => { const chip = event.chip; const chipIndex = 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; } }); } /** Checks whether an event comes from inside a chip element. */ private _originatesFromChip(event: Event): boolean { let currentElement = event.target as HTMLElement | null; while (currentElement && currentElement !== this._elementRef.nativeElement) { if (currentElement.classList.contains('mat-chip')) { return true; } currentElement = currentElement.parentElement; } return false; } /** Checks whether any of the chips is focused. */ private _hasFocusedChip() { return this.chips.some(chip => chip._hasFocus); } /** Syncs the list's state with the individual chips. */ private _syncChipsState() { if (this.chips) { this.chips.forEach(chip => { chip.disabled = this._disabled; chip._chipListMultiple = this.multiple; }); } } }