355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
/**
|
|
* @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, coerceNumberProperty} from '@angular/cdk/coercion';
|
|
import {Platform} from '@angular/cdk/platform';
|
|
import {
|
|
AfterViewInit,
|
|
ContentChildren,
|
|
Directive,
|
|
ElementRef,
|
|
inject,
|
|
Input,
|
|
NgZone,
|
|
OnDestroy,
|
|
QueryList,
|
|
ANIMATION_MODULE_TYPE,
|
|
Injector,
|
|
} from '@angular/core';
|
|
import {
|
|
_StructuralStylesLoader,
|
|
MAT_RIPPLE_GLOBAL_OPTIONS,
|
|
RippleConfig,
|
|
RippleGlobalOptions,
|
|
RippleRenderer,
|
|
RippleTarget,
|
|
} from '@angular/material/core';
|
|
import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
|
|
import {Subscription, merge} from 'rxjs';
|
|
import {
|
|
MatListItemLine,
|
|
MatListItemTitle,
|
|
MatListItemIcon,
|
|
MatListItemAvatar,
|
|
} from './list-item-sections';
|
|
import {MAT_LIST_CONFIG} from './tokens';
|
|
|
|
@Directive({
|
|
host: {
|
|
'[attr.aria-disabled]': 'disabled',
|
|
},
|
|
})
|
|
/** @docs-private */
|
|
export abstract class MatListBase {
|
|
_isNonInteractive: boolean = true;
|
|
|
|
/** Whether ripples for all list items is disabled. */
|
|
@Input()
|
|
get disableRipple(): boolean {
|
|
return this._disableRipple;
|
|
}
|
|
set disableRipple(value: BooleanInput) {
|
|
this._disableRipple = coerceBooleanProperty(value);
|
|
}
|
|
private _disableRipple: boolean = false;
|
|
|
|
/**
|
|
* Whether the entire list is disabled. When disabled, the list itself and each of its list items
|
|
* are disabled.
|
|
*/
|
|
@Input()
|
|
get disabled(): boolean {
|
|
return this._disabled;
|
|
}
|
|
set disabled(value: BooleanInput) {
|
|
this._disabled = coerceBooleanProperty(value);
|
|
}
|
|
private _disabled = false;
|
|
|
|
protected _defaultOptions = inject(MAT_LIST_CONFIG, {optional: true});
|
|
}
|
|
|
|
@Directive({
|
|
host: {
|
|
'[class.mdc-list-item--disabled]': 'disabled',
|
|
'[attr.aria-disabled]': 'disabled',
|
|
'[attr.disabled]': '(_isButtonElement && disabled) || null',
|
|
},
|
|
})
|
|
/** @docs-private */
|
|
export abstract class MatListItemBase implements AfterViewInit, OnDestroy, RippleTarget {
|
|
_elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
protected _ngZone = inject(NgZone);
|
|
private _listBase = inject(MatListBase, {optional: true});
|
|
private _platform = inject(Platform);
|
|
|
|
/** Query list matching list-item line elements. */
|
|
abstract _lines: QueryList<MatListItemLine> | undefined;
|
|
|
|
/** Query list matching list-item title elements. */
|
|
abstract _titles: QueryList<MatListItemTitle> | undefined;
|
|
|
|
/**
|
|
* Element reference to the unscoped content in a list item.
|
|
*
|
|
* Unscoped content is user-projected text content in a list item that is
|
|
* not part of an explicit line or title.
|
|
*/
|
|
abstract _unscopedContent: ElementRef<HTMLSpanElement> | undefined;
|
|
|
|
/** Host element for the list item. */
|
|
_hostElement: HTMLElement;
|
|
|
|
/** indicate whether the host element is a button or not */
|
|
_isButtonElement: boolean;
|
|
|
|
/** Whether animations are disabled. */
|
|
_noopAnimations: boolean;
|
|
|
|
@ContentChildren(MatListItemAvatar, {descendants: false}) _avatars: QueryList<never>;
|
|
@ContentChildren(MatListItemIcon, {descendants: false}) _icons: QueryList<never>;
|
|
|
|
/**
|
|
* The number of lines this list item should reserve space for. If not specified,
|
|
* lines are inferred based on the projected content.
|
|
*
|
|
* Explicitly specifying the number of lines is useful if you want to acquire additional
|
|
* space and enable the wrapping of text. The unscoped text content of a list item will
|
|
* always be able to take up the remaining space of the item, unless it represents the title.
|
|
*
|
|
* A maximum of three lines is supported as per the Material Design specification.
|
|
*/
|
|
@Input()
|
|
set lines(lines: number | string | null) {
|
|
this._explicitLines = coerceNumberProperty(lines, null);
|
|
this._updateItemLines(false);
|
|
}
|
|
_explicitLines: number | null = null;
|
|
|
|
/** Whether ripples for list items are disabled. */
|
|
@Input()
|
|
get disableRipple(): boolean {
|
|
return (
|
|
this.disabled ||
|
|
this._disableRipple ||
|
|
this._noopAnimations ||
|
|
!!this._listBase?.disableRipple
|
|
);
|
|
}
|
|
set disableRipple(value: BooleanInput) {
|
|
this._disableRipple = coerceBooleanProperty(value);
|
|
}
|
|
private _disableRipple: boolean = false;
|
|
|
|
/** Whether the list-item is disabled. */
|
|
@Input()
|
|
get disabled(): boolean {
|
|
return this._disabled || !!this._listBase?.disabled;
|
|
}
|
|
set disabled(value: BooleanInput) {
|
|
this._disabled = coerceBooleanProperty(value);
|
|
}
|
|
private _disabled = false;
|
|
|
|
private _subscriptions = new Subscription();
|
|
private _rippleRenderer: RippleRenderer | null = null;
|
|
|
|
/** Whether the list item has unscoped text content. */
|
|
_hasUnscopedTextContent: boolean = false;
|
|
|
|
/**
|
|
* Implemented as part of `RippleTarget`.
|
|
* @docs-private
|
|
*/
|
|
rippleConfig: RippleConfig & RippleGlobalOptions;
|
|
|
|
/**
|
|
* Implemented as part of `RippleTarget`.
|
|
* @docs-private
|
|
*/
|
|
get rippleDisabled(): boolean {
|
|
return this.disableRipple || !!this.rippleConfig.disabled;
|
|
}
|
|
|
|
constructor(...args: unknown[]);
|
|
|
|
constructor() {
|
|
inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
|
|
const globalRippleOptions = inject<RippleGlobalOptions>(MAT_RIPPLE_GLOBAL_OPTIONS, {
|
|
optional: true,
|
|
});
|
|
const animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
|
|
|
|
this.rippleConfig = globalRippleOptions || {};
|
|
this._hostElement = this._elementRef.nativeElement;
|
|
this._isButtonElement = this._hostElement.nodeName.toLowerCase() === 'button';
|
|
this._noopAnimations = animationMode === 'NoopAnimations';
|
|
|
|
if (this._listBase && !this._listBase._isNonInteractive) {
|
|
this._initInteractiveListItem();
|
|
}
|
|
|
|
// If no type attribute is specified for a host `<button>` element, set it to `button`. If a
|
|
// type attribute is already specified, we do nothing. We do this for backwards compatibility.
|
|
// TODO: Determine if we intend to continue doing this for the MDC-based list.
|
|
if (this._isButtonElement && !this._hostElement.hasAttribute('type')) {
|
|
this._hostElement.setAttribute('type', 'button');
|
|
}
|
|
}
|
|
|
|
ngAfterViewInit() {
|
|
this._monitorProjectedLinesAndTitle();
|
|
this._updateItemLines(true);
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this._subscriptions.unsubscribe();
|
|
if (this._rippleRenderer !== null) {
|
|
this._rippleRenderer._removeTriggerEvents();
|
|
}
|
|
}
|
|
|
|
/** Whether the list item has icons or avatars. */
|
|
_hasIconOrAvatar() {
|
|
return !!(this._avatars.length || this._icons.length);
|
|
}
|
|
|
|
private _initInteractiveListItem() {
|
|
this._hostElement.classList.add('mat-mdc-list-item-interactive');
|
|
this._rippleRenderer = new RippleRenderer(
|
|
this,
|
|
this._ngZone,
|
|
this._hostElement,
|
|
this._platform,
|
|
inject(Injector),
|
|
);
|
|
this._rippleRenderer.setupTriggerEvents(this._hostElement);
|
|
}
|
|
|
|
/**
|
|
* Subscribes to changes in the projected title and lines. Triggers a
|
|
* item lines update whenever a change occurs.
|
|
*/
|
|
private _monitorProjectedLinesAndTitle() {
|
|
this._ngZone.runOutsideAngular(() => {
|
|
this._subscriptions.add(
|
|
merge(this._lines!.changes, this._titles!.changes).subscribe(() =>
|
|
this._updateItemLines(false),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the lines of the list item. Based on the projected user content and optional
|
|
* explicit lines setting, the visual appearance of the list item is determined.
|
|
*
|
|
* This method should be invoked whenever the projected user content changes, or
|
|
* when the explicit lines have been updated.
|
|
*
|
|
* @param recheckUnscopedContent Whether the projected unscoped content should be re-checked.
|
|
* The unscoped content is not re-checked for every update as it is a rather expensive check
|
|
* for content that is expected to not change very often.
|
|
*/
|
|
_updateItemLines(recheckUnscopedContent: boolean) {
|
|
// If the updated is triggered too early before the view and content is initialized,
|
|
// we just skip the update. After view initialization the update is triggered again.
|
|
if (!this._lines || !this._titles || !this._unscopedContent) {
|
|
return;
|
|
}
|
|
|
|
// Re-check the DOM for unscoped text content if requested. This needs to
|
|
// happen before any computation or sanity checks run as these rely on the
|
|
// result of whether there is unscoped text content or not.
|
|
if (recheckUnscopedContent) {
|
|
this._checkDomForUnscopedTextContent();
|
|
}
|
|
|
|
// Sanity check the list item lines and title in the content. This is a dev-mode only
|
|
// check that can be dead-code eliminated by Terser in production.
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
sanityCheckListItemContent(this);
|
|
}
|
|
|
|
const numberOfLines = this._explicitLines ?? this._inferLinesFromContent();
|
|
const unscopedContentEl = this._unscopedContent.nativeElement;
|
|
|
|
// Update the list item element to reflect the number of lines.
|
|
this._hostElement.classList.toggle('mat-mdc-list-item-single-line', numberOfLines <= 1);
|
|
this._hostElement.classList.toggle('mdc-list-item--with-one-line', numberOfLines <= 1);
|
|
this._hostElement.classList.toggle('mdc-list-item--with-two-lines', numberOfLines === 2);
|
|
this._hostElement.classList.toggle('mdc-list-item--with-three-lines', numberOfLines === 3);
|
|
|
|
// If there is no title and the unscoped content is the is the only line, the
|
|
// unscoped text content will be treated as the title of the list-item.
|
|
if (this._hasUnscopedTextContent) {
|
|
const treatAsTitle = this._titles.length === 0 && numberOfLines === 1;
|
|
unscopedContentEl.classList.toggle('mdc-list-item__primary-text', treatAsTitle);
|
|
unscopedContentEl.classList.toggle('mdc-list-item__secondary-text', !treatAsTitle);
|
|
} else {
|
|
unscopedContentEl.classList.remove('mdc-list-item__primary-text');
|
|
unscopedContentEl.classList.remove('mdc-list-item__secondary-text');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Infers the number of lines based on the projected user content. This is useful
|
|
* if no explicit number of lines has been specified on the list item.
|
|
*
|
|
* The number of lines is inferred based on whether there is a title, the number of
|
|
* additional lines (secondary/tertiary). An additional line is acquired if there is
|
|
* unscoped text content.
|
|
*/
|
|
private _inferLinesFromContent() {
|
|
let numOfLines = this._titles!.length + this._lines!.length;
|
|
if (this._hasUnscopedTextContent) {
|
|
numOfLines += 1;
|
|
}
|
|
return numOfLines;
|
|
}
|
|
|
|
/** Checks whether the list item has unscoped text content. */
|
|
private _checkDomForUnscopedTextContent() {
|
|
this._hasUnscopedTextContent = Array.from<ChildNode>(
|
|
this._unscopedContent!.nativeElement.childNodes,
|
|
)
|
|
.filter(node => node.nodeType !== node.COMMENT_NODE)
|
|
.some(node => !!(node.textContent && node.textContent.trim()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanity checks the configuration of the list item with respect to the amount
|
|
* of lines, whether there is a title, or if there is unscoped text content.
|
|
*
|
|
* The checks are extracted into a top-level function that can be dead-code
|
|
* eliminated by Terser or other optimizers in production mode.
|
|
*/
|
|
function sanityCheckListItemContent(item: MatListItemBase) {
|
|
const numTitles = item._titles!.length;
|
|
const numLines = item._lines!.length;
|
|
|
|
if (numTitles > 1) {
|
|
console.warn('A list item cannot have multiple titles.');
|
|
}
|
|
if (numTitles === 0 && numLines > 0) {
|
|
console.warn('A list item line can only be used if there is a list item title.');
|
|
}
|
|
if (
|
|
numTitles === 0 &&
|
|
item._hasUnscopedTextContent &&
|
|
item._explicitLines !== null &&
|
|
item._explicitLines > 1
|
|
) {
|
|
console.warn('A list item cannot have wrapping content without a title.');
|
|
}
|
|
if (numLines > 2 || (numLines === 2 && item._hasUnscopedTextContent)) {
|
|
console.warn('A list item can have at maximum three lines.');
|
|
}
|
|
}
|