/** * @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 {FocusKeyManager} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, Input, OnDestroy, QueryList, ViewEncapsulation, booleanAttribute, numberAttribute, inject, } from '@angular/core'; import {Observable, Subject, merge} from 'rxjs'; import {startWith, switchMap, takeUntil} from 'rxjs/operators'; import {MatChip, MatChipEvent} from './chip'; import {MatChipAction} from './chip-action'; /** * Basic container component for the MatChip component. * * Extended by MatChipListbox and MatChipGrid for different interaction patterns. */ @Component({ selector: 'mat-chip-set', template: ` `, styleUrl: 'chip-set.css', host: { 'class': 'mat-mdc-chip-set mdc-evolution-chip-set', '(keydown)': '_handleKeydown($event)', '[attr.role]': 'role', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatChipSet implements AfterViewInit, OnDestroy { protected _elementRef = inject>(ElementRef); protected _changeDetectorRef = inject(ChangeDetectorRef); private _dir = inject(Directionality, {optional: true}); /** Index of the last destroyed chip that had focus. */ private _lastDestroyedFocusedChipIndex: number | null = null; /** Used to manage focus within the chip list. */ protected _keyManager: FocusKeyManager; /** Subject that emits when the component has been destroyed. */ protected _destroyed = new Subject(); /** Role to use if it hasn't been overwritten by the user. */ protected _defaultRole = 'presentation'; /** Combined stream of all of the child chips' focus events. */ get chipFocusChanges(): Observable { return this._getChipStream(chip => chip._onFocus); } /** Combined stream of all of the child chips' destroy events. */ get chipDestroyedChanges(): Observable { return this._getChipStream(chip => chip.destroyed); } /** Combined stream of all of the child chips' remove events. */ get chipRemovedChanges(): Observable { return this._getChipStream(chip => chip.removed); } /** Whether the chip set is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled; } set disabled(value: boolean) { this._disabled = value; this._syncChipsState(); } protected _disabled: boolean = false; /** Whether the chip list contains chips or not. */ get empty(): boolean { return !this._chips || this._chips.length === 0; } /** The ARIA role applied to the chip set. */ @Input() get role(): string | null { if (this._explicitRole) { return this._explicitRole; } return this.empty ? null : this._defaultRole; } /** Tabindex of the chip set. */ @Input({ transform: (value: unknown) => (value == null ? 0 : numberAttribute(value)), }) tabIndex: number = 0; set role(value: string | null) { this._explicitRole = value; } private _explicitRole: string | null = null; /** Whether any of the chips inside of this chip-set has focus. */ get focused(): boolean { return this._hasFocusedChip(); } /** The chips that are part of this chip set. */ @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; /** Flat list of all the actions contained within the chips. */ _chipActions = new QueryList(); constructor(...args: unknown[]); constructor() {} ngAfterViewInit() { this._setUpFocusManagement(); this._trackChipSetChanges(); this._trackDestroyedFocusedChip(); } ngOnDestroy() { this._keyManager?.destroy(); this._chipActions.destroy(); this._destroyed.next(); this._destroyed.complete(); } /** Checks whether any of the chips is focused. */ protected _hasFocusedChip() { return this._chips && this._chips.some(chip => chip._hasFocus()); } /** Syncs the chip-set's state with the individual chips. */ protected _syncChipsState() { this._chips?.forEach(chip => { chip._chipListDisabled = this._disabled; chip._changeDetectorRef.markForCheck(); }); } /** Dummy method for subclasses to override. Base chip set cannot be focused. */ focus() {} /** Handles keyboard events on the chip set. */ _handleKeydown(event: KeyboardEvent) { if (this._originatesFromChip(event)) { this._keyManager.onKeydown(event); } } /** * 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. */ protected _isValidIndex(index: number): boolean { return index >= 0 && index < this._chips.length; } /** * Removes the `tabindex` from the chip set and resets it back afterwards, allowing the * user to tab out of it. This prevents the set from capturing focus and redirecting * it back to the first chip, creating a focus trap, if it user tries to tab away. */ protected _allowFocusEscape() { const previous = this._elementRef.nativeElement.tabIndex; if (previous !== -1) { // Set the tabindex directly on the element, instead of going through // the data binding, because we aren't guaranteed that change detection // will run quickly enough to allow focus to escape. this._elementRef.nativeElement.tabIndex = -1; // Note that this needs to be a `setTimeout`, because a `Promise.resolve` // doesn't allow enough time for the focus to escape. setTimeout(() => (this._elementRef.nativeElement.tabIndex = previous)); } } /** * Gets a stream of events from all the chips within the set. * The stream will automatically incorporate any newly-added chips. */ protected _getChipStream( mappingFunction: (chip: C) => Observable, ): Observable { return this._chips.changes.pipe( startWith(null), switchMap(() => merge(...(this._chips as QueryList).map(mappingFunction))), ); } /** Checks whether an event comes from inside a chip element. */ protected _originatesFromChip(event: Event): boolean { let currentElement = event.target as HTMLElement | null; while (currentElement && currentElement !== this._elementRef.nativeElement) { if (currentElement.classList.contains('mat-mdc-chip')) { return true; } currentElement = currentElement.parentElement; } return false; } /** Sets up the chip set's focus management logic. */ private _setUpFocusManagement() { // Create a flat `QueryList` containing the actions of all of the chips. // This allows us to navigate both within the chip and move to the next/previous // one using the existing `ListKeyManager`. this._chips.changes.pipe(startWith(this._chips)).subscribe((chips: QueryList) => { const actions: MatChipAction[] = []; chips.forEach(chip => chip._getActions().forEach(action => actions.push(action))); this._chipActions.reset(actions); this._chipActions.notifyOnChanges(); }); this._keyManager = new FocusKeyManager(this._chipActions) .withVerticalOrientation() .withHorizontalOrientation(this._dir ? this._dir.value : 'ltr') .withHomeAndEnd() .skipPredicate(action => this._skipPredicate(action)); // Keep the manager active index in sync so that navigation picks // up from the current chip if the user clicks into the list directly. this.chipFocusChanges.pipe(takeUntil(this._destroyed)).subscribe(({chip}) => { const action = chip._getSourceAction(document.activeElement as Element); if (action) { this._keyManager.updateActiveItem(action); } }); this._dir?.change .pipe(takeUntil(this._destroyed)) .subscribe(direction => this._keyManager.withHorizontalOrientation(direction)); } /** * Determines if key manager should avoid putting a given chip action in the tab index. Skip * non-interactive and disabled actions since the user can't do anything with them. */ protected _skipPredicate(action: MatChipAction): boolean { // Skip chips that the user cannot interact with. `mat-chip-set` does not permit focusing disabled // chips. return !action.isInteractive || action.disabled; } /** Listens to changes in the chip set and syncs up the state of the individual chips. */ private _trackChipSetChanges() { 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._redirectDestroyedChipFocus(); }); } /** Starts tracking the destroyed chips in order to capture the focused one. */ private _trackDestroyedFocusedChip() { this.chipDestroyedChanges.pipe(takeUntil(this._destroyed)).subscribe((event: MatChipEvent) => { const chipArray = this._chips.toArray(); const chipIndex = chipArray.indexOf(event.chip); // If the focused chip is destroyed, save its index so that we can move focus to the next // chip. We only save the index here, rather than move the focus immediately, because we want // to wait until the chip is removed from the chip list before focusing the next one. This // allows us to keep focus on the same index if the chip gets swapped out. if (this._isValidIndex(chipIndex) && event.chip._hasFocus()) { this._lastDestroyedFocusedChipIndex = chipIndex; } }); } /** * Finds the next appropriate chip to move focus to, * if the currently-focused chip is destroyed. */ private _redirectDestroyedChipFocus() { if (this._lastDestroyedFocusedChipIndex == null) { return; } if (this._chips.length) { const newIndex = Math.min(this._lastDestroyedFocusedChipIndex, this._chips.length - 1); const chipToFocus = this._chips.toArray()[newIndex]; if (chipToFocus.disabled) { // If we're down to one disabled chip, move focus back to the set. if (this._chips.length === 1) { this.focus(); } else { this._keyManager.setPreviousItemActive(); } } else { chipToFocus.focus(); } } else { this.focus(); } this._lastDestroyedFocusedChipIndex = null; } }