/** * @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 {END, HOME} from '@angular/cdk/keycodes'; import { AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, EventEmitter, forwardRef, Input, Optional, Output, QueryList, ViewEncapsulation } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {MDCChipSetFoundation} from '@material/chips'; import {merge, Observable, Subscription} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; import {MatChip, MatChipEvent} from './chip'; import {MatChipOption, MatChipSelectionChange} from './chip-option'; import {MatChipSet} from './chip-set'; /** Change event object that is emitted when the chip listbox value has changed. */ export class MatChipListboxChange { constructor( /** Chip listbox that emitted the event. */ public source: MatChipListbox, /** Value of the chip listbox when the event was emitted. */ public value: any) { } } /** * Provider Expression that allows mat-chip-listbox to register as a ControlValueAccessor. * This allows it to support [(ngModel)]. * @docs-private */ export const MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatChipListbox), multi: true }; /** * An extension of the MatChipSet component that supports chip selection. * Used with MatChipOption chips. */ @Component({ moduleId: module.id, selector: 'mat-chip-listbox', template: '', styleUrls: ['chips.css'], inputs: ['tabIndex'], host: { 'class': 'mat-mdc-chip-set mat-mdc-chip-listbox mdc-chip-set', '[attr.role]': 'role', '[tabIndex]': 'empty ? -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-multiselectable]': 'multiple', '[attr.aria-orientation]': 'ariaOrientation', '[class.mat-mdc-chip-list-disabled]': 'disabled', '[class.mat-mdc-chip-list-required]': 'required', '(focus)': 'focus()', '(blur)': '_blur()', '(keydown)': '_keydown($event)', '[id]': '_uid', }, providers: [MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatChipListbox extends MatChipSet implements AfterContentInit, ControlValueAccessor { /** Subscription to selection changes in the chips. */ private _chipSelectionSubscription: Subscription | null; /** Subscription to blur changes in the chips. */ private _chipBlurSubscription: Subscription | null; /** Subscription to focus changes in the chips. */ private _chipFocusSubscription: Subscription | null; /** The FocusKeyManager which handles focus. */ _keyManager: FocusKeyManager; /** * 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 ARIA role applied to the chip listbox. */ get role(): string | null { return this.empty ? null : 'listbox'; } /** 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._updateMdcSelectionClasses(); this._syncListboxProperties(); } private _multiple: boolean = false; /** The array of selected chips inside the chip listbox. */ get selected(): MatChipOption[] | MatChipOption { const selectedChips = this._chips.toArray().filter(chip => chip.selected); return this.multiple ? selectedChips : selectedChips[0]; } /** Orientation of the chip list. */ @Input('aria-orientation') ariaOrientation: 'horizontal' | 'vertical' = 'horizontal'; /** * Whether or not this chip listbox is selectable. * * When a chip listbox is not selectable, the selected states for all * the chips inside the chip listbox are always ignored. */ @Input() get selectable(): boolean { return this._selectable; } set selectable(value: boolean) { this._selectable = coerceBooleanProperty(value); this._updateMdcSelectionClasses(); this._syncListboxProperties(); } protected _selectable: boolean = true; /** * 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; this._initializeSelection(); } private _compareWith = (o1: any, o2: any) => o1 === o2; /** Whether this chip listbox is required. */ @Input() get required(): boolean { return this._required; } set required(value: boolean) { this._required = coerceBooleanProperty(value); } protected _required: boolean = false; /** 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 events. */ get chipFocusChanges(): Observable { return merge(...this._chips.map(chip => chip._onFocus)); } /** Combined stream of all of the child chips' blur events. */ get chipBlurChanges(): Observable { return merge(...this._chips.map(chip => chip._onBlur)); } /** The value of the listbox, which is the combined value of the selected chips. */ @Input() get value(): any { return this._value; } set value(value: any) { this.writeValue(value); this._value = value; } protected _value: any; /** Event emitted when the selected chip listbox value has been changed by the user. */ @Output() readonly change: EventEmitter = new EventEmitter(); @ContentChildren(MatChipOption, { // 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, _changeDetectorRef: ChangeDetectorRef, @Optional() _dir: Directionality) { super(_elementRef, _changeDetectorRef, _dir); this._chipSetAdapter.selectChipAtIndex = (index: number, selected: boolean) => { this._setSelected(index, selected); }; // Reinitialize the foundation with our overridden adapter this._chipSetFoundation = new MDCChipSetFoundation(this._chipSetAdapter); this._updateMdcSelectionClasses(); } ngAfterContentInit() { super.ngAfterContentInit(); this._initKeyManager(); this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { // Update listbox selectable/multiple properties on chips this._syncListboxProperties(); // Reset chips selected/deselected status this._initializeSelection(); // Check to see if we have a destroyed chip and need to refocus this._updateFocusForDestroyedChips(); }); } /** * Focuses the first selected chip in this chip listbox, or the first non-disabled chip when there * are no selected chips. */ focus(): void { if (this.disabled) { return; } const firstSelectedChip = this._getFirstSelectedChip(); if (firstSelectedChip) { const firstSelectedChipIndex = this._chips.toArray().indexOf(firstSelectedChip); this._keyManager.setActiveItem(firstSelectedChipIndex); } else if (this._chips.length > 0) { this._keyManager.setFirstItemActive(); } } /** * Implemented as part of ControlValueAccessor. * @docs-private */ writeValue(value: any): void { if (this._chips) { this._setSelectionByValue(value, false); } } /** * 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; } /** Selects all chips with value. */ _setSelectionByValue(value: any, isUserInput: boolean = true) { this._clearSelection(); if (Array.isArray(value)) { value.forEach(currentValue => this._selectValue(currentValue, isUserInput)); } 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); } } } } /** Selects or deselects a chip by id. */ _setSelected(index: number, selected: boolean) { const chip = this._chips.toArray()[index]; if (chip && chip.selected != selected) { chip.toggleSelected(true); } } /** When blurred, marks the field as touched when focus moved outside the chip listbox. */ _blur() { if (this.disabled) { return; } if (!this.focused) { this._keyManager.setActiveItem(-1); } // Wait to see if focus moves to an indivdual chip. setTimeout(() => { if (!this.focused) { this._propagateChanges(); this._markAsTouched(); } }); } /** * Removes the `tabindex` from the chip listbox and resets it back afterwards, allowing the * user to tab out of it. This prevents the listbox from capturing focus and redirecting * it back to the first chip, creating a focus trap, if it user tries to tab away. */ _allowFocusEscape() { const previousTabIndex = this.tabIndex; if (this.tabIndex !== -1) { this.tabIndex = -1; setTimeout(() => { this.tabIndex = previousTabIndex; this._changeDetectorRef.markForCheck(); }); } } /** * Handles custom keyboard shortcuts, and passes other keyboard events to the keyboard manager. */ _keydown(event: KeyboardEvent) { if (this._originatesFromChip(event)) { if (event.keyCode === HOME) { this._keyManager.setFirstItemActive(); event.preventDefault(); } else if (event.keyCode === END) { this._keyManager.setLastItemActive(); event.preventDefault(); } else { this._keyManager.onKeydown(event); } } } /** Marks the field as touched */ private _markAsTouched() { this._onTouched(); this._changeDetectorRef.markForCheck(); } /** 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 MatChipListboxChange(this, valueToEmit)); this._onChange(valueToEmit); this._changeDetectorRef.markForCheck(); } /** * Initializes the chip listbox selection state to reflect any chips that were preselected. */ private _initializeSelection() { setTimeout(() => { // Defer setting the value in order to avoid the "Expression // has changed after it was checked" errors from Angular. this._chips.forEach(chip => { if (chip.selected) { this._chipSetFoundation.select(chip.id); } }); }); } /** * Deselects every chip in the listbox. * @param skip Chip that should not be deselected. */ private _clearSelection(skip?: MatChip): void { this._chips.forEach(chip => { if (chip !== skip) { chip.deselect(); } }); } /** * 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(); } return correspondingChip; } /** Syncs the chip-listbox selection state with the individual chips. */ private _syncListboxProperties() { if (this._chips) { // Defer setting the value in order to avoid the "Expression // has changed after it was checked" errors from Angular. Promise.resolve().then(() => { this._chips.forEach(chip => { chip._chipListMultiple = this.multiple; chip.chipListSelectable = this._selectable; chip._changeDetectorRef.markForCheck(); }); }); } } /** Sets the mdc classes for single vs multi selection. */ private _updateMdcSelectionClasses() { this._setMdcClass('mdc-chip-set--filter', this.selectable && this.multiple); this._setMdcClass('mdc-chip-set--choice', this.selectable && !this.multiple); } /** Initializes the key manager to manage focus. */ private _initKeyManager() { 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(); }); } /** Returns the first selected chip in this listbox, or undefined if no chips are selected. */ private _getFirstSelectedChip(): MatChipOption | undefined { if (Array.isArray(this.selected)) { return this.selected.length ? this.selected[0] : undefined; } else { return this.selected; } } /** Unsubscribes from all chip events. */ protected _dropSubscriptions() { super._dropSubscriptions(); if (this._chipSelectionSubscription) { this._chipSelectionSubscription.unsubscribe(); this._chipSelectionSubscription = null; } 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._listenToChipsSelection(); this._listenToChipsFocus(); this._listenToChipsBlur(); } /** 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 MatChipOption); if (this._isValidIndex(chipIndex)) { this._keyManager.updateActiveItemIndex(chipIndex); } }); } /** Subscribes to chip blur events. */ private _listenToChipsBlur(): void { this._chipBlurSubscription = this.chipBlurChanges.subscribe(() => { this._blur(); }); } /** Subscribes to selection changes in the option chips. */ private _listenToChipsSelection(): void { this._chipSelectionSubscription = this.chipSelectionChanges.subscribe( (chipSelectionChange: MatChipSelectionChange) => { this._chipSetFoundation.handleChipSelection( chipSelectionChange.source.id, chipSelectionChange.selected, false); if (chipSelectionChange.isUserInput) { this._propagateChanges(); } }); } /** * If the amount of chips changed, we need to update the * key manager state and focus the next closest chip. */ private _updateFocusForDestroyedChips() { // Move focus to the closest chip. If no other chips remain, focus the chip-listbox 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; } }