/** * @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 {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, EventEmitter, InjectionToken, Input, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewEncapsulation, inject, } from '@angular/core'; import {ThemePalette} from '@angular/material/core'; import {MatListBase, MatListItemBase} from './list-base'; import {LIST_OPTION, ListOption, MatListOptionTogglePosition} from './list-option-types'; import {MatListItemLine, MatListItemTitle} from './list-item-sections'; import {NgTemplateOutlet} from '@angular/common'; import {CdkObserveContent} from '@angular/cdk/observers'; /** * Injection token that can be used to reference instances of an `SelectionList`. It serves * as alternative token to an actual implementation which would result in circular references. * @docs-private */ export const SELECTION_LIST = new InjectionToken('SelectionList'); /** * Interface describing the containing list of a list option. This is used to avoid * circular dependencies between the list-option and the selection list. * @docs-private */ export interface SelectionList extends MatListBase { multiple: boolean; color: ThemePalette; selectedOptions: SelectionModel; hideSingleSelectionIndicator: boolean; compareWith: (o1: any, o2: any) => boolean; _value: string[] | null; _reportValueChange(): void; _emitChangeEvent(options: MatListOption[]): void; _onTouched(): void; } @Component({ selector: 'mat-list-option', exportAs: 'matListOption', styleUrl: 'list-option.css', host: { 'class': 'mat-mdc-list-item mat-mdc-list-option mdc-list-item', 'role': 'option', // As per MDC, only list items without checkbox or radio indicator should receive the // `--selected` class. '[class.mdc-list-item--selected]': 'selected && !_selectionList.multiple && _selectionList.hideSingleSelectionIndicator', // Based on the checkbox/radio position and whether there are icons or avatars, we apply MDC's // list-item `--leading` and `--trailing` classes. '[class.mdc-list-item--with-leading-avatar]': '_hasProjected("avatars", "before")', '[class.mdc-list-item--with-leading-icon]': '_hasProjected("icons", "before")', '[class.mdc-list-item--with-trailing-icon]': '_hasProjected("icons", "after")', '[class.mat-mdc-list-option-with-trailing-avatar]': '_hasProjected("avatars", "after")', // Based on the checkbox/radio position, we apply the `--leading` or `--trailing` MDC classes // which ensure that the checkbox/radio is positioned correctly within the list item. '[class.mdc-list-item--with-leading-checkbox]': '_hasCheckboxAt("before")', '[class.mdc-list-item--with-trailing-checkbox]': '_hasCheckboxAt("after")', '[class.mdc-list-item--with-leading-radio]': '_hasRadioAt("before")', '[class.mdc-list-item--with-trailing-radio]': '_hasRadioAt("after")', // Utility class that makes it easier to target the case where there's both a leading // and a trailing icon. Avoids having to write out all the combinations. '[class.mat-mdc-list-item-both-leading-and-trailing]': '_hasBothLeadingAndTrailing()', '[class.mat-accent]': 'color !== "primary" && color !== "warn"', '[class.mat-warn]': 'color === "warn"', '[class._mat-animation-noopable]': '_noopAnimations', '[attr.aria-selected]': 'selected', '(blur)': '_handleBlur()', '(click)': '_toggleOnInteraction()', }, templateUrl: 'list-option.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ {provide: MatListItemBase, useExisting: MatListOption}, {provide: LIST_OPTION, useExisting: MatListOption}, ], imports: [NgTemplateOutlet, CdkObserveContent], }) export class MatListOption extends MatListItemBase implements ListOption, OnInit, OnDestroy { private _selectionList = inject(SELECTION_LIST); private _changeDetectorRef = inject(ChangeDetectorRef); @ContentChildren(MatListItemLine, {descendants: true}) _lines: QueryList; @ContentChildren(MatListItemTitle, {descendants: true}) _titles: QueryList; @ViewChild('unscopedContent') _unscopedContent: ElementRef; /** * Emits when the selected state of the option has changed. * Use to facilitate two-data binding to the `selected` property. * @docs-private */ @Output() readonly selectedChange: EventEmitter = new EventEmitter(); /** Whether the label should appear before or after the checkbox/radio. Defaults to 'after' */ @Input() togglePosition: MatListOptionTogglePosition = 'after'; /** * Whether the label should appear before or after the checkbox/radio. Defaults to 'after' * * @deprecated Use `togglePosition` instead. * @breaking-change 17.0.0 */ @Input() get checkboxPosition(): MatListOptionTogglePosition { return this.togglePosition; } set checkboxPosition(value: MatListOptionTogglePosition) { this.togglePosition = value; } /** * Theme color of the list option. This sets the color of the checkbox/radio. * 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() get color(): ThemePalette { return this._color || this._selectionList.color; } set color(newValue: ThemePalette) { this._color = newValue; } private _color: ThemePalette; /** Value of the option */ @Input() get value(): any { return this._value; } set value(newValue: any) { if (this.selected && newValue !== this.value && this._inputsInitialized) { this.selected = false; } this._value = newValue; } private _value: any; /** Whether the option is selected. */ @Input() get selected(): boolean { return this._selectionList.selectedOptions.isSelected(this); } set selected(value: BooleanInput) { const isSelected = coerceBooleanProperty(value); if (isSelected !== this._selected) { this._setSelected(isSelected); if (isSelected || this._selectionList.multiple) { this._selectionList._reportValueChange(); } } } private _selected = false; /** * This is set to true after the first OnChanges cycle so we don't * clear the value of `selected` in the first cycle. */ private _inputsInitialized = false; ngOnInit() { const list = this._selectionList; if (list._value && list._value.some(value => list.compareWith(this._value, value))) { this._setSelected(true); } const wasSelected = this._selected; // List options that are selected at initialization can't be reported properly to the form // control. This is because it takes some time until the selection-list knows about all // available options. Also it can happen that the ControlValueAccessor has an initial value // that should be used instead. Deferring the value change report to the next tick ensures // that the form control value is not being overwritten. Promise.resolve().then(() => { if (this._selected || wasSelected) { this.selected = true; this._changeDetectorRef.markForCheck(); } }); this._inputsInitialized = true; } override ngOnDestroy(): void { super.ngOnDestroy(); if (this.selected) { // We have to delay this until the next tick in order // to avoid changed after checked errors. Promise.resolve().then(() => { this.selected = false; }); } } /** Toggles the selection state of the option. */ toggle(): void { this.selected = !this.selected; } /** Allows for programmatic focusing of the option. */ focus(): void { this._hostElement.focus(); } /** Gets the text label of the list option. Used for the typeahead functionality in the list. */ getLabel() { const titleElement = this._titles?.get(0)?._elementRef.nativeElement; // If there is no explicit title element, the unscoped text content // is treated as the list item title. const labelEl = titleElement || this._unscopedContent?.nativeElement; return labelEl?.textContent || ''; } /** Whether a checkbox is shown at the given position. */ _hasCheckboxAt(position: MatListOptionTogglePosition): boolean { return this._selectionList.multiple && this._getTogglePosition() === position; } /** Where a radio indicator is shown at the given position. */ _hasRadioAt(position: MatListOptionTogglePosition): boolean { return ( !this._selectionList.multiple && this._getTogglePosition() === position && !this._selectionList.hideSingleSelectionIndicator ); } /** Whether icons or avatars are shown at the given position. */ _hasIconsOrAvatarsAt(position: 'before' | 'after'): boolean { return this._hasProjected('icons', position) || this._hasProjected('avatars', position); } /** Gets whether the given type of element is projected at the specified position. */ _hasProjected(type: 'icons' | 'avatars', position: 'before' | 'after'): boolean { // If the checkbox/radio is shown at the specified position, neither icons or // avatars can be shown at the position. return ( this._getTogglePosition() !== position && (type === 'avatars' ? this._avatars.length !== 0 : this._icons.length !== 0) ); } _handleBlur() { this._selectionList._onTouched(); } /** Gets the current position of the checkbox/radio. */ _getTogglePosition() { return this.togglePosition || 'after'; } /** * Sets the selected state of the option. * @returns Whether the value has changed. */ _setSelected(selected: boolean): boolean { if (selected === this._selected) { return false; } this._selected = selected; if (selected) { this._selectionList.selectedOptions.select(this); } else { this._selectionList.selectedOptions.deselect(this); } this.selectedChange.emit(selected); this._changeDetectorRef.markForCheck(); return true; } /** * Notifies Angular that the option needs to be checked in the next change detection run. * Mainly used to trigger an update of the list option if the disabled state of the selection * list changed. */ _markForCheck() { this._changeDetectorRef.markForCheck(); } /** Toggles the option's value based on a user interaction. */ _toggleOnInteraction() { if (!this.disabled) { if (this._selectionList.multiple) { this.selected = !this.selected; this._selectionList._emitChangeEvent([this]); } else if (!this.selected) { this.selected = true; this._selectionList._emitChangeEvent([this]); } } } /** Sets the tabindex of the list option. */ _setTabindex(value: number) { this._hostElement.setAttribute('tabindex', value + ''); } protected _hasBothLeadingAndTrailing(): boolean { const hasLeading = this._hasProjected('avatars', 'before') || this._hasProjected('icons', 'before') || this._hasCheckboxAt('before') || this._hasRadioAt('before'); const hasTrailing = this._hasProjected('icons', 'after') || this._hasProjected('avatars', 'after') || this._hasCheckboxAt('after') || this._hasRadioAt('after'); return hasLeading && hasTrailing; } }