/**
* @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.dev/license
*/
import {
AfterContentInit,
booleanAttribute,
ChangeDetectionStrategy,
Component,
ContentChildren,
EventEmitter,
forwardRef,
inject,
Input,
OnDestroy,
Output,
QueryList,
ViewEncapsulation,
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {Observable} from 'rxjs';
import {startWith, takeUntil} from 'rxjs/operators';
import {TAB} from '@angular/cdk/keycodes';
import {MatChip, MatChipEvent} from './chip';
import {MatChipOption, MatChipSelectionChange} from './chip-option';
import {MatChipSet} from './chip-set';
import {MatChipAction} from './chip-action';
import {MAT_CHIPS_DEFAULT_OPTIONS} from './tokens';
/** 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({
selector: 'mat-chip-listbox',
template: `
`,
styleUrl: 'chip-set.css',
host: {
'class': 'mdc-evolution-chip-set mat-mdc-chip-listbox',
'[attr.role]': 'role',
'[tabIndex]': '(disabled || empty) ? -1 : tabIndex',
// TODO: replace this binding with use of AriaDescriber
'[attr.aria-describedby]': '_ariaDescribedby || null',
'[attr.aria-required]': 'role ? required : null',
'[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)',
},
providers: [MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatChipListbox
extends MatChipSet
implements AfterContentInit, OnDestroy, ControlValueAccessor
{
/**
* 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 = () => {};
// TODO: MDC uses `grid` here
protected override _defaultRole = 'listbox';
/** Value that was assigned before the listbox was initialized. */
private _pendingInitialValue: any;
/** Default chip options. */
private _defaultOptions = inject(MAT_CHIPS_DEFAULT_OPTIONS, {optional: true});
/** Whether the user should be allowed to select multiple chips. */
@Input({transform: booleanAttribute})
get multiple(): boolean {
return this._multiple;
}
set multiple(value: boolean) {
this._multiple = value;
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({transform: booleanAttribute})
get selectable(): boolean {
return this._selectable;
}
set selectable(value: boolean) {
this._selectable = value;
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() compareWith: (o1: any, o2: any) => boolean = (o1: any, o2: any) => o1 === o2;
/** Whether this chip listbox is required. */
@Input({transform: booleanAttribute})
required: boolean = false;
/** Whether checkmark indicator for single-selection options is hidden. */
@Input({transform: booleanAttribute})
get hideSingleSelectionIndicator(): boolean {
return this._hideSingleSelectionIndicator;
}
set hideSingleSelectionIndicator(value: boolean) {
this._hideSingleSelectionIndicator = value;
this._syncListboxProperties();
}
private _hideSingleSelectionIndicator: boolean =
this._defaultOptions?.hideSingleSelectionIndicator ?? false;
/** Combined stream of all of the child chips' selection change events. */
get chipSelectionChanges(): Observable {
return this._getChipStream(chip => chip.selectionChange);
}
/** Combined stream of all of the child chips' blur events. */
get chipBlurChanges(): Observable {
return this._getChipStream(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,
})
// We need an initializer here to avoid a TS error. The value will be set in `ngAfterViewInit`.
override _chips: QueryList = undefined!;
ngAfterContentInit() {
if (this._pendingInitialValue !== undefined) {
Promise.resolve().then(() => {
this._setSelectionByValue(this._pendingInitialValue, false);
this._pendingInitialValue = undefined;
});
}
this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
// Update listbox selectable/multiple properties on chips
this._syncListboxProperties();
});
this.chipBlurChanges.pipe(takeUntil(this._destroyed)).subscribe(() => this._blur());
this.chipSelectionChanges.pipe(takeUntil(this._destroyed)).subscribe(event => {
if (!this.multiple) {
this._chips.forEach(chip => {
if (chip !== event.source) {
chip._setSelectedState(false, false, false);
}
});
}
if (event.isUserInput) {
this._propagateChanges();
}
});
}
/**
* Focuses the first selected chip in this chip listbox, or the first non-disabled chip when there
* are no selected chips.
*/
override focus(): void {
if (this.disabled) {
return;
}
const firstSelectedChip = this._getFirstSelectedChip();
if (firstSelectedChip && !firstSelectedChip.disabled) {
firstSelectedChip.focus();
} else if (this._chips.length > 0) {
this._keyManager.setFirstItemActive();
} else {
this._elementRef.nativeElement.focus();
}
}
/**
* Implemented as part of ControlValueAccessor.
* @docs-private
*/
writeValue(value: any): void {
if (this._chips) {
this._setSelectionByValue(value, false);
} else if (value != null) {
this._pendingInitialValue = 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;
}
/** 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 {
this._selectValue(value, isUserInput);
}
}
/** When blurred, marks the field as touched when focus moved outside the chip listbox. */
_blur() {
if (!this.disabled) {
// Wait to see if focus moves to an individual chip.
setTimeout(() => {
if (!this.focused) {
this._markAsTouched();
}
});
}
}
_keydown(event: KeyboardEvent) {
if (event.keyCode === TAB) {
super._allowFocusEscape();
}
}
/** Marks the field as touched */
private _markAsTouched() {
this._onTouched();
this._changeDetectorRef.markForCheck();
}
/** Emits change event to set the model value. */
private _propagateChanges(): void {
let valueToEmit: any = null;
if (Array.isArray(this.selected)) {
valueToEmit = this.selected.map(chip => chip.value);
} else {
valueToEmit = this.selected ? this.selected.value : undefined;
}
this._value = valueToEmit;
this.change.emit(new MatChipListboxChange(this, valueToEmit));
this._onChange(valueToEmit);
this._changeDetectorRef.markForCheck();
}
/**
* 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): 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._chipListHideSingleSelectionIndicator = this.hideSingleSelectionIndicator;
chip._changeDetectorRef.markForCheck();
});
});
}
}
/** 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;
}
}
/**
* Determines if key manager should avoid putting a given chip action in the tab index. Skip
* non-interactive actions since the user can't do anything with them.
*/
protected override _skipPredicate(action: MatChipAction): boolean {
// Override the skip predicate in the base class to avoid skipping disabled chips. Allow
// disabled chip options to receive focus to align with WAI ARIA recommendation. Normally WAI
// ARIA's instructions are to exclude disabled items from the tab order, but it makes a few
// exceptions for compound widgets.
//
// From [Developing a Keyboard Interface](
// https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
// "For the following composite widget elements, keep them focusable when disabled: Options in a
// Listbox..."
return !action.isInteractive;
}
}