/** * @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 {_IdGenerator, FocusMonitor} from '@angular/cdk/a11y'; import {BACKSPACE, DELETE} from '@angular/cdk/keycodes'; import {DOCUMENT} from '@angular/common'; import { ANIMATION_MODULE_TYPE, AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ContentChildren, DoCheck, ElementRef, EventEmitter, Injector, Input, NgZone, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewEncapsulation, afterNextRender, booleanAttribute, inject, } from '@angular/core'; import { _StructuralStylesLoader, MAT_RIPPLE_GLOBAL_OPTIONS, MatRippleLoader, RippleGlobalOptions, } from '@angular/material/core'; import {Subject, Subscription, merge} from 'rxjs'; import {MatChipAction} from './chip-action'; import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; import {MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/private'; /** Represents an event fired on an individual `mat-chip`. */ export interface MatChipEvent { /** The chip the event was fired on. */ chip: MatChip; } /** * Material design styled Chip base component. Used inside the MatChipSet component. * * Extended by MatChipOption and MatChipRow for different interaction patterns. */ @Component({ selector: 'mat-basic-chip, [mat-basic-chip], mat-chip, [mat-chip]', exportAs: 'matChip', templateUrl: 'chip.html', styleUrl: 'chip.css', host: { 'class': 'mat-mdc-chip', '[class]': '"mat-" + (color || "primary")', '[class.mdc-evolution-chip]': '!_isBasicChip', '[class.mdc-evolution-chip--disabled]': 'disabled', '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-disabled]': 'disabled', '[class.mat-mdc-basic-chip]': '_isBasicChip', '[class.mat-mdc-standard-chip]': '!_isBasicChip', '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[class._mat-animation-noopable]': '_animationsDisabled', '[id]': 'id', '[attr.role]': 'role', '[attr.aria-label]': 'ariaLabel', '(keydown)': '_handleKeydown($event)', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, providers: [{provide: MAT_CHIP, useExisting: MatChip}], imports: [MatChipAction], }) export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck, OnDestroy { _changeDetectorRef = inject(ChangeDetectorRef); _elementRef = inject>(ElementRef); protected _ngZone = inject(NgZone); private _focusMonitor = inject(FocusMonitor); private _globalRippleOptions = inject(MAT_RIPPLE_GLOBAL_OPTIONS, { optional: true, }); protected _document = inject(DOCUMENT); /** Emits when the chip is focused. */ readonly _onFocus = new Subject(); /** Emits when the chip is blurred. */ readonly _onBlur = new Subject(); /** Whether this chip is a basic (unstyled) chip. */ _isBasicChip: boolean; /** Role for the root of the chip. */ @Input() role: string | null = null; /** Whether the chip has focus. */ private _hasFocusInternal = false; /** Whether moving focus into the chip is pending. */ private _pendingFocus: boolean; /** Subscription to changes in the chip's actions. */ private _actionChanges: Subscription | undefined; /** Whether animations for the chip are enabled. */ _animationsDisabled: boolean; /** All avatars present in the chip. */ @ContentChildren(MAT_CHIP_AVATAR, {descendants: true}) protected _allLeadingIcons: QueryList; /** All trailing icons present in the chip. */ @ContentChildren(MAT_CHIP_TRAILING_ICON, {descendants: true}) protected _allTrailingIcons: QueryList; /** All remove icons present in the chip. */ @ContentChildren(MAT_CHIP_REMOVE, {descendants: true}) protected _allRemoveIcons: QueryList; _hasFocus() { return this._hasFocusInternal; } /** A unique id for the chip. If none is supplied, it will be auto-generated. */ @Input() id: string = inject(_IdGenerator).getId('mat-mdc-chip-'); // TODO(#26104): Consider deprecating and using `_computeAriaAccessibleName` instead. // `ariaLabel` may be unnecessary, and `_computeAriaAccessibleName` only supports // datepicker's use case. /** ARIA label for the content of the chip. */ @Input('aria-label') ariaLabel: string | null = null; // TODO(#26104): Consider deprecating and using `_computeAriaAccessibleName` instead. // `ariaDescription` may be unnecessary, and `_computeAriaAccessibleName` only supports // datepicker's use case. /** ARIA description for the content of the chip. */ @Input('aria-description') ariaDescription: string | null = null; /** Id of a span that contains this chip's aria description. */ _ariaDescriptionId = `${this.id}-aria-description`; /** Whether the chip list is disabled. */ _chipListDisabled: boolean = false; private _textElement!: HTMLElement; /** * The value of the chip. Defaults to the content inside * the `mat-mdc-chip-action-label` element. */ @Input() get value(): any { return this._value !== undefined ? this._value : this._textElement.textContent!.trim(); } set value(value: any) { this._value = value; } protected _value: any; // TODO: should be typed as `ThemePalette` but internal apps pass in arbitrary strings. /** * Theme color of the chip. This API is supported in M2 themes only, it has no * effect in M3 themes. * * For information on applying color variants in M3, see * https://material.angular.io/guide/theming#using-component-color-variants. */ @Input() color?: string | null; /** * Determines whether or not the chip displays the remove styling and emits (removed) events. */ @Input({transform: booleanAttribute}) removable: boolean = true; /** * Colors the chip for emphasis as if it were selected. */ @Input({transform: booleanAttribute}) highlighted: boolean = false; /** Whether the ripple effect is disabled or not. */ @Input({transform: booleanAttribute}) disableRipple: boolean = false; /** Whether the chip is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled || this._chipListDisabled; } set disabled(value: boolean) { this._disabled = value; } private _disabled = false; /** Emitted when a chip is to be removed. */ @Output() readonly removed: EventEmitter = new EventEmitter(); /** Emitted when the chip is destroyed. */ @Output() readonly destroyed: EventEmitter = new EventEmitter(); /** The unstyled chip selector for this component. */ protected basicChipAttrName = 'mat-basic-chip'; /** The chip's leading icon. */ @ContentChild(MAT_CHIP_AVATAR) leadingIcon: MatChipAvatar; /** The chip's trailing icon. */ @ContentChild(MAT_CHIP_TRAILING_ICON) trailingIcon: MatChipTrailingIcon; /** The chip's trailing remove icon. */ @ContentChild(MAT_CHIP_REMOVE) removeIcon: MatChipRemove; /** Action receiving the primary set of user interactions. */ @ViewChild(MatChipAction) primaryAction: MatChipAction; /** * Handles the lazy creation of the MatChip ripple. * Used to improve initial load time of large applications. */ private _rippleLoader: MatRippleLoader = inject(MatRippleLoader); protected _injector = inject(Injector); constructor(...args: unknown[]); constructor() { inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader); const animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); this._animationsDisabled = animationMode === 'NoopAnimations'; this._monitorFocus(); this._rippleLoader?.configureRipple(this._elementRef.nativeElement, { className: 'mat-mdc-chip-ripple', disabled: this._isRippleDisabled(), }); } ngOnInit() { // This check needs to happen in `ngOnInit` so the overridden value of // `basicChipAttrName` coming from base classes can be picked up. const element = this._elementRef.nativeElement; this._isBasicChip = element.hasAttribute(this.basicChipAttrName) || element.tagName.toLowerCase() === this.basicChipAttrName; } ngAfterViewInit() { this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label')!; if (this._pendingFocus) { this._pendingFocus = false; this.focus(); } } ngAfterContentInit(): void { // Since the styling depends on the presence of some // actions, we have to mark for check on changes. this._actionChanges = merge( this._allLeadingIcons.changes, this._allTrailingIcons.changes, this._allRemoveIcons.changes, ).subscribe(() => this._changeDetectorRef.markForCheck()); } ngDoCheck(): void { this._rippleLoader.setDisabled(this._elementRef.nativeElement, this._isRippleDisabled()); } ngOnDestroy() { this._focusMonitor.stopMonitoring(this._elementRef); this._rippleLoader?.destroyRipple(this._elementRef.nativeElement); this._actionChanges?.unsubscribe(); this.destroyed.emit({chip: this}); this.destroyed.complete(); } /** * Allows for programmatic removal of the chip. * * Informs any listeners of the removal request. Does not remove the chip from the DOM. */ remove(): void { if (this.removable) { this.removed.emit({chip: this}); } } /** Whether or not the ripple should be disabled. */ _isRippleDisabled(): boolean { return ( this.disabled || this.disableRipple || this._animationsDisabled || this._isBasicChip || !!this._globalRippleOptions?.disabled ); } /** Returns whether the chip has a trailing icon. */ _hasTrailingIcon() { return !!(this.trailingIcon || this.removeIcon); } /** Handles keyboard events on the chip. */ _handleKeydown(event: KeyboardEvent) { // Ignore backspace events where the user is holding down the key // so that we don't accidentally remove too many chips. if ((event.keyCode === BACKSPACE && !event.repeat) || event.keyCode === DELETE) { event.preventDefault(); this.remove(); } } /** Allows for programmatic focusing of the chip. */ focus(): void { if (!this.disabled) { // If `focus` is called before `ngAfterViewInit`, we won't have access to the primary action. // This can happen if the consumer tries to focus a chip immediately after it is added. // Queue the method to be called again on init. if (this.primaryAction) { this.primaryAction.focus(); } else { this._pendingFocus = true; } } } /** Gets the action that contains a specific target node. */ _getSourceAction(target: Node): MatChipAction | undefined { return this._getActions().find(action => { const element = action._elementRef.nativeElement; return element === target || element.contains(target); }); } /** Gets all of the actions within the chip. */ _getActions(): MatChipAction[] { const result: MatChipAction[] = []; if (this.primaryAction) { result.push(this.primaryAction); } if (this.removeIcon) { result.push(this.removeIcon); } if (this.trailingIcon) { result.push(this.trailingIcon); } return result; } /** Handles interactions with the primary action of the chip. */ _handlePrimaryActionInteraction() { // Empty here, but is overwritten in child classes. } /** Starts the focus monitoring process on the chip. */ private _monitorFocus() { this._focusMonitor.monitor(this._elementRef, true).subscribe(origin => { const hasFocus = origin !== null; if (hasFocus !== this._hasFocusInternal) { this._hasFocusInternal = hasFocus; if (hasFocus) { this._onFocus.next({chip: this}); } else { // When animations are enabled, Angular may end up removing the chip from the DOM a little // earlier than usual, causing it to be blurred and throwing off the logic in the chip list // that moves focus not the next item. To work around the issue, we defer marking the chip // as not focused until after the next render. afterNextRender(() => this._ngZone.run(() => this._onBlur.next({chip: this})), { injector: this._injector, }); } } }); } }