sass-references/angular-material/material/list/list-base.ts

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.');
}
}