diff --git a/scripts/check-mdc-exports-config.ts b/scripts/check-mdc-exports-config.ts
index 780fa76f489b..da69cdf7e4ed 100644
--- a/scripts/check-mdc-exports-config.ts
+++ b/scripts/check-mdc-exports-config.ts
@@ -5,6 +5,18 @@ export const config = {
// Exclude them from this check since they aren't part of the public API.
skippedSymbols: [/^_/],
skippedExports: {
+ 'mdc-core': [
+ // The line directive is not used by the MDC-based list and therefore does
+ // not need to be re-exposed.
+ 'MatLine',
+ 'MatLineModule',
+ ],
+ 'mdc-list': [
+ // These classes are docs-private and have actual public classes in the
+ // MDC-based list, such as `MatListItemIcon` or `MatListItemAvatar`.
+ 'MatListAvatarCssMatStyler',
+ 'MatListIconCssMatStyler',
+ ],
'mdc-chips': [
// These components haven't been implemented for MDC due to a different accessibility pattern.
'MatChipListChange',
diff --git a/scripts/check-mdc-tests-config.ts b/scripts/check-mdc-tests-config.ts
index 17017e95e54f..a5f6bd9d0b59 100644
--- a/scripts/check-mdc-tests-config.ts
+++ b/scripts/check-mdc-tests-config.ts
@@ -100,13 +100,11 @@ export const config = {
'should not change focus when pressing HOME with a modifier key',
'should not change focus when pressing END with a modifier key',
- // MDC does not support the common CTRL + A keyboard shortcut.
- // Tracked with: https://github.com/material-components/material-components-web/issues/6366
- 'should select all items using ctrl + a',
- 'should not select disabled items when pressing ctrl + a',
- 'should select all items using ctrl + a if some items are selected',
- 'should deselect all with ctrl + a if all options are selected',
- 'should dispatch the selectionChange event when selecting via ctrl + a',
+ // The indirect descendant test scenario never worked (even in the non-MDC list) and
+ // therefore this test has been removed.
+ 'should pick up indirect descendant lines',
+ // MDC-based list does not support more than three lines.
+ 'should apply a particular class to lists with more than 3 lines',
],
'mdc-progress-bar': [
// These tests are verifying implementation details that are not relevant for MDC.
diff --git a/scripts/check-mdc-tests.ts b/scripts/check-mdc-tests.ts
index 2b7e9f54781c..9a8af1e73a3b 100644
--- a/scripts/check-mdc-tests.ts
+++ b/scripts/check-mdc-tests.ts
@@ -91,7 +91,7 @@ function getTestNames(files: string[]): string[] {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
- node.expression.text === 'it'
+ (node.expression.text === 'it' || node.expression.text === 'xit')
) {
// Note that this is a little naive since it'll take the literal text of the test
// name expression which could include things like string concatenation. It's fine
diff --git a/src/dev-app/mdc-list/mdc-list-demo.html b/src/dev-app/mdc-list/mdc-list-demo.html
index 31baa520f704..e8b2d376c888 100644
--- a/src/dev-app/mdc-list/mdc-list-demo.html
+++ b/src/dev-app/mdc-list/mdc-list-demo.html
@@ -1,9 +1,11 @@
+
+
+ Title
+
+ Title
+ This is unscoped text content that is supposed to not wrap. The list has only
+ acquired two lines and therefore there is no space for wrapping.
+
+
+ Title
+ This is unscoped text content that is supposed to wrap to the third line.
+ The list item acquire spaces for three lines and text should have an ellipsis in the
+ third line upon text overflow.
+
+
+ Title
+ This is unscoped text content that is supposed to not wrap. The list has only
+ acquired two lines (automatically) and therefore there is no space for wrapping.
+
+
+ Title
+ Secondary line
+ Tertiary line
+
+
+
+ Show item boxes
+
+
Line alignment
- {{ link.name }}
- Not in an matLine
+ {{ link.name }}
+ Unscoped contentFirst
- Second
- Not in an matLine
+ Second
+ Unscoped content
@@ -207,19 +240,19 @@
Icon alignment in selection list
- info
+ info
Bananas
- info
+ info
Oranges
- info
+ info
Cake
- info
+ info
Fries
diff --git a/src/dev-app/mdc-list/mdc-list-demo.scss b/src/dev-app/mdc-list/mdc-list-demo.scss
index 3f5fd9822baf..30bed5b5e262 100644
--- a/src/dev-app/mdc-list/mdc-list-demo.scss
+++ b/src/dev-app/mdc-list/mdc-list-demo.scss
@@ -15,6 +15,10 @@
.mat-mdc-icon-button {
color: rgba(0, 0, 0, 0.12);
}
+
+ &.demo-show-boxes .mat-mdc-list-item {
+ border: 1px solid grey;
+ }
}
.demo-secondary-text {
diff --git a/src/dev-app/mdc-list/mdc-list-demo.ts b/src/dev-app/mdc-list/mdc-list-demo.ts
index 5296c897d33f..a33fb58b67d4 100644
--- a/src/dev-app/mdc-list/mdc-list-demo.ts
+++ b/src/dev-app/mdc-list/mdc-list-demo.ts
@@ -27,10 +27,10 @@ export class MdcListDemo {
messages: {from: string; subject: string; message: string; image: string}[] = [
{
- from: 'Nancy',
+ from: 'John',
subject: 'Brunch?',
message: 'Did you want to go on Sunday? I was thinking that might work.',
- image: 'https://angular.io/generated/images/bios/cindygreenekaplan.jpg',
+ image: 'https://angular.io/generated/images/bios/devversion.jpg',
},
{
from: 'Mary',
@@ -49,6 +49,7 @@ export class MdcListDemo {
links: {name: string}[] = [{name: 'Inbox'}, {name: 'Outbox'}, {name: 'Spam'}, {name: 'Trash'}];
thirdLine = false;
+ showBoxes = false;
infoClicked = false;
selectionListDisabled = false;
selectionListRippleDisabled = false;
diff --git a/src/material-experimental/mdc-core/public-api.ts b/src/material-experimental/mdc-core/public-api.ts
index 00cbeab3a498..7ca80f2d379c 100644
--- a/src/material-experimental/mdc-core/public-api.ts
+++ b/src/material-experimental/mdc-core/public-api.ts
@@ -29,8 +29,6 @@ export {
MatCommonModule,
MatDateFormats,
MATERIAL_SANITY_CHECKS,
- MatLine,
- MatLineModule,
MatNativeDateModule,
MatPseudoCheckbox,
MatPseudoCheckboxModule,
diff --git a/src/material-experimental/mdc-list/BUILD.bazel b/src/material-experimental/mdc-list/BUILD.bazel
index bc18968cb298..43fb6793d02c 100644
--- a/src/material-experimental/mdc-list/BUILD.bazel
+++ b/src/material-experimental/mdc-list/BUILD.bazel
@@ -26,6 +26,7 @@ ng_module(
deps = [
"//src:dev_mode_types",
"//src/cdk/collections",
+ "//src/cdk/observers",
"//src/material-experimental/mdc-core",
"//src/material/divider",
"//src/material/list",
diff --git a/src/material-experimental/mdc-list/list-base.ts b/src/material-experimental/mdc-list/list-base.ts
index fb9547ac74b6..13081d1d950c 100644
--- a/src/material-experimental/mdc-list/list-base.ts
+++ b/src/material-experimental/mdc-list/list-base.ts
@@ -6,10 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
+import {BooleanInput, coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion';
import {Platform} from '@angular/cdk/platform';
import {
- AfterContentInit,
+ AfterViewInit,
ContentChildren,
Directive,
ElementRef,
@@ -26,20 +26,15 @@ import {
RippleGlobalOptions,
RippleRenderer,
RippleTarget,
- setLines,
} from '@angular/material-experimental/mdc-core';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
-import {Subscription} from 'rxjs';
-import {startWith} from 'rxjs/operators';
-import {MatListAvatarCssMatStyler, MatListIconCssMatStyler} from './list-styling';
-
-function toggleClass(el: Element, className: string, on: boolean) {
- if (on) {
- el.classList.add(className);
- } else {
- el.classList.remove(className);
- }
-}
+import {Subscription, merge} from 'rxjs';
+import {
+ MatListItemLine,
+ MatListItemTitle,
+ MatListItemIcon,
+ MatListItemAvatar,
+} from './list-item-sections';
@Directive({
host: {
@@ -48,12 +43,20 @@ function toggleClass(el: Element, className: string, on: boolean) {
},
})
/** @docs-private */
-export abstract class MatListItemBase implements AfterContentInit, OnDestroy, RippleTarget {
+export abstract class MatListItemBase implements AfterViewInit, OnDestroy, RippleTarget {
/** Query list matching list-item line elements. */
- abstract lines: QueryList>;
+ abstract _lines: QueryList | undefined;
- /** Element reference referring to the primary list item text. */
- abstract _itemText: ElementRef;
+ /** Query list matching list-item title elements. */
+ abstract _titles: QueryList | 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 | undefined;
/** Host element for the list item. */
_hostElement: HTMLElement;
@@ -61,8 +64,25 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
/** Whether animations are disabled. */
_noopAnimations: boolean;
- @ContentChildren(MatListAvatarCssMatStyler, {descendants: false}) _avatars: QueryList;
- @ContentChildren(MatListIconCssMatStyler, {descendants: false}) _icons: QueryList;
+ @ContentChildren(MatListItemAvatar, {descendants: false}) _avatars: QueryList;
+ @ContentChildren(MatListItemIcon, {descendants: false}) _icons: QueryList;
+
+ /**
+ * 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;
@Input()
get disableRipple(): boolean {
@@ -88,6 +108,9 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
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
@@ -102,7 +125,7 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
return this.disableRipple || !!this.rippleConfig.disabled;
}
- constructor(
+ protected constructor(
public _elementRef: ElementRef,
protected _ngZone: NgZone,
private _listBase: MatListBase,
@@ -131,8 +154,9 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
}
}
- ngAfterContentInit() {
- this._monitorLines();
+ ngAfterViewInit() {
+ this._monitorProjectedLinesAndTitle();
+ this._updateItemLines(true);
}
ngOnDestroy() {
@@ -144,7 +168,11 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
/** Gets the label for the list item. This is used for the typeahead. */
_getItemLabel(): string {
- return this._itemText ? this._itemText.nativeElement.textContent || '' : '';
+ 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 ? labelEl.textContent ?? '' : '';
}
/** Whether the list item has icons or avatars. */
@@ -164,33 +192,95 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
}
/**
- * Subscribes to changes in `MatLine` content children and annotates them
- * appropriately when they change.
+ * Subscribes to changes in the projected title and lines. Triggers a
+ * item lines update whenever a change occurs.
*/
- private _monitorLines() {
+ private _monitorProjectedLinesAndTitle() {
this._ngZone.runOutsideAngular(() => {
this._subscriptions.add(
- this.lines.changes
- .pipe(startWith(this.lines))
- .subscribe((lines: QueryList>) => {
- toggleClass(this._hostElement, 'mat-mdc-list-item-single-line', lines.length <= 1);
- toggleClass(this._hostElement, 'mdc-list-item--with-one-line', lines.length <= 1);
-
- lines.forEach((line: ElementRef, index: number) => {
- toggleClass(this._hostElement, 'mdc-list-item--with-two-lines', lines.length === 2);
- toggleClass(this._hostElement, 'mdc-list-item--with-three-lines', lines.length === 3);
- toggleClass(
- line.nativeElement,
- 'mdc-list-item__primary-text',
- index === 0 && lines.length > 1,
- );
- toggleClass(line.nativeElement, 'mdc-list-item__secondary-text', index !== 0);
- });
- setLines(lines, this._elementRef, 'mat-mdc');
- }),
+ 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(
+ this._unscopedContent!.nativeElement.childNodes,
+ )
+ .filter(node => node.nodeType !== node.COMMENT_NODE)
+ .some(node => !!(node.textContent && node.textContent.trim()));
+ }
}
@Directive({
@@ -223,3 +313,33 @@ export abstract class MatListBase {
}
private _disabled = false;
}
+
+/**
+ * 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._titles!.length;
+
+ if (numTitles > 1) {
+ throw Error('A list item cannot have multiple titles.');
+ }
+ if (numTitles === 0 && numLines > 0) {
+ throw Error('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
+ ) {
+ throw Error('A list item cannot have wrapping content without a title.');
+ }
+ if (numLines > 2 || (numLines === 2 && item._hasUnscopedTextContent)) {
+ throw Error('A list item can have at maximum three lines.');
+ }
+}
diff --git a/src/material-experimental/mdc-list/list-item-sections.ts b/src/material-experimental/mdc-list/list-item-sections.ts
new file mode 100644
index 000000000000..78101de89688
--- /dev/null
+++ b/src/material-experimental/mdc-list/list-item-sections.ts
@@ -0,0 +1,100 @@
+/**
+ * @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.io/license
+ */
+
+import {Directive, ElementRef, Inject, Optional} from '@angular/core';
+import {LIST_OPTION, ListOption} from './list-option-types';
+
+/**
+ * Directive capturing the title of a list item. A list item usually consists of a
+ * title and optional secondary or tertiary lines.
+ *
+ * Text content for the title never wraps. There can only be a single title per list item.
+ */
+@Directive({
+ selector: '[matListItemTitle]',
+ host: {'class': 'mat-mdc-list-item-title mdc-list-item__primary-text'},
+})
+export class MatListItemTitle {
+ constructor(public _elementRef: ElementRef) {}
+}
+
+/**
+ * Directive capturing a line in a list item. A list item usually consists of a
+ * title and optional secondary or tertiary lines.
+ *
+ * Text content inside a line never wraps. There can be at maximum two lines per list item.
+ */
+@Directive({
+ selector: '[matListItemLine]',
+ host: {'class': 'mat-mdc-list-item-line mdc-list-item__secondary-text'},
+})
+export class MatListItemLine {
+ constructor(public _elementRef: ElementRef) {}
+}
+
+/**
+ * Directive matching an optional meta section for list items.
+ *
+ * List items can reserve space at the end of an item to display a control,
+ * button or additional text content.
+ */
+@Directive({
+ selector: '[matListItemMeta]',
+ host: {'class': 'mat-mdc-list-item-meta mdc-list-item__end'},
+})
+export class MatListItemMeta {}
+
+/**
+ * @docs-private
+ *
+ * MDC uses the very intuitively named classes `.mdc-list-item__start` and `.mat-list-item__end`
+ * to position content such as icons or checkboxes that comes either before or after the text
+ * content respectively. This directive detects the placement of the checkbox and applies the
+ * correct MDC class to position the icon/avatar on the opposite side.
+ */
+@Directive({
+ host: {
+ // MDC uses intuitively named classes `.mdc-list-item__start` and `.mat-list-item__end`
+ // to position content such as icons or checkboxes that comes either before or after the text
+ // content respectively. This directive detects the placement of the checkbox and applies the
+ // correct MDC class to position the icon/avatar on the opposite side.
+ '[class.mdc-list-item__start]': '_isAlignedAtStart()',
+ '[class.mdc-list-item__end]': '!_isAlignedAtStart()',
+ },
+})
+export class _MatListItemGraphicBase {
+ constructor(@Optional() @Inject(LIST_OPTION) public _listOption: ListOption) {}
+
+ _isAlignedAtStart() {
+ // By default, in all list items the graphic is aligned at start. In list options,
+ // the graphic is only aligned at start if the checkbox is at the end.
+ return !this._listOption || this._listOption?._getCheckboxPosition() === 'after';
+ }
+}
+
+/**
+ * Directive matching an optional avatar within a list item.
+ *
+ * List items can reserve space at the beginning of an item to display an avatar.
+ */
+@Directive({
+ selector: '[matListItemAvatar]',
+ host: {'class': 'mat-mdc-list-item-avatar'},
+})
+export class MatListItemAvatar extends _MatListItemGraphicBase {}
+
+/**
+ * Directive matching an optional icon within a list item.
+ *
+ * List items can reserve space at the beginning of an item to display an icon.
+ */
+@Directive({
+ selector: '[matListItemIcon]',
+ host: {'class': 'mat-mdc-list-item-icon'},
+})
+export class MatListItemIcon extends _MatListItemGraphicBase {}
diff --git a/src/material-experimental/mdc-list/list-item.html b/src/material-experimental/mdc-list/list-item.html
index e00076915380..e52a1e9491c4 100644
--- a/src/material-experimental/mdc-list/list-item.html
+++ b/src/material-experimental/mdc-list/list-item.html
@@ -1,19 +1,16 @@
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
@@ -36,8 +36,13 @@
-
-
+
+
+
+
+
+
diff --git a/src/material-experimental/mdc-list/list-option.ts b/src/material-experimental/mdc-list/list-option.ts
index 52eef6d07eb8..89b9de13b32c 100644
--- a/src/material-experimental/mdc-list/list-option.ts
+++ b/src/material-experimental/mdc-list/list-option.ts
@@ -29,7 +29,6 @@ import {
ViewEncapsulation,
} from '@angular/core';
import {
- MatLine,
MAT_RIPPLE_GLOBAL_OPTIONS,
RippleGlobalOptions,
ThemePalette,
@@ -37,6 +36,7 @@ import {
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
import {MatListBase, MatListItemBase} from './list-base';
import {LIST_OPTION, ListOption, MatListOptionCheckboxPosition} from './list-option-types';
+import {MatListItemLine, MatListItemTitle} from './list-item-sections';
/**
* Injection token that can be used to reference instances of an `SelectionList`. It serves
@@ -94,11 +94,10 @@ export interface SelectionList extends MatListBase {
],
})
export class MatListOption extends MatListItemBase implements ListOption, OnInit, OnDestroy {
- /**
- * 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;
+ @ContentChildren(MatListItemLine, {descendants: true}) _lines: QueryList;
+ @ContentChildren(MatListItemTitle, {descendants: true}) _titles: QueryList;
+ @ViewChild('unscopedContent') _unscopedContent: ElementRef;
+ @ViewChild('text') _itemText: ElementRef;
/**
* Emits when the selected state of the option has changed.
@@ -108,12 +107,6 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit
@Output()
readonly selectedChange: EventEmitter = new EventEmitter();
- @ViewChild('text') _itemText: ElementRef;
-
- @ContentChildren(MatLine, {read: ElementRef, descendants: true}) lines: QueryList<
- ElementRef
- >;
-
/** Whether the label should appear before or after the checkbox. Defaults to 'after' */
@Input() checkboxPosition: MatListOptionCheckboxPosition = 'after';
@@ -159,6 +152,12 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit
}
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;
+
constructor(
element: ElementRef,
ngZone: NgZone,
diff --git a/src/material-experimental/mdc-list/list-styling.ts b/src/material-experimental/mdc-list/list-styling.ts
deleted file mode 100644
index 571340f3fa4e..000000000000
--- a/src/material-experimental/mdc-list/list-styling.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * @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.io/license
- */
-
-import {Directive, Inject, Optional} from '@angular/core';
-import {LIST_OPTION, ListOption} from './list-option-types';
-
-/**
- * MDC uses the very intuitively named classes `.mdc-list-item__start` and `.mat-list-item__end`
- * to position content such as icons or checkboxes that comes either before or after the text
- * content respectively. This directive detects the placement of the checkbox and applies the
- * correct MDC class to position the icon/avatar on the opposite side.
- * @docs-private
- */
-@Directive({
- selector: '[mat-list-avatar], [matListAvatar], [mat-list-icon], [matListIcon]',
- host: {
- '[class.mdc-list-item__start]': '_isAlignedAtStart()',
- '[class.mdc-list-item__end]': '!_isAlignedAtStart()',
- },
-})
-export class MatListGraphicAlignmentStyler {
- constructor(@Optional() @Inject(LIST_OPTION) public _listOption: ListOption) {}
-
- _isAlignedAtStart() {
- // By default, in all list items the graphic is aligned at start. In list options,
- // the graphic is only aligned at start if the checkbox is at the end.
- return !this._listOption || this._listOption?._getCheckboxPosition() === 'after';
- }
-}
-
-/**
- * Directive whose purpose is to add the mat- CSS styling to this selector.
- * @docs-private
- */
-@Directive({
- selector: '[mat-list-avatar], [matListAvatar]',
- host: {'class': 'mat-mdc-list-avatar'},
-})
-export class MatListAvatarCssMatStyler {}
-
-/**
- * Directive whose purpose is to add the mat- CSS styling to this selector.
- * @docs-private
- */
-@Directive({
- selector: '[mat-list-icon], [matListIcon]',
- host: {'class': 'mat-mdc-list-icon'},
-})
-export class MatListIconCssMatStyler {}
-
-/**
- * Directive whose purpose is to add the mat- CSS styling to this selector.
- * @docs-private
- */
-@Directive({
- selector: '[mat-subheader], [matSubheader]',
- // TODO(mmalerba): MDC's subheader font looks identical to the list item font, figure out why and
- // make a change in one of the repos to visually distinguish.
- host: {'class': 'mat-mdc-subheader mdc-list-group__subheader'},
-})
-export class MatListSubheaderCssMatStyler {}
diff --git a/src/material-experimental/mdc-list/list.scss b/src/material-experimental/mdc-list/list.scss
index 4a27011f7146..039dc1c320a4 100644
--- a/src/material-experimental/mdc-list/list.scss
+++ b/src/material-experimental/mdc-list/list.scss
@@ -29,7 +29,7 @@
bottom: 0;
}
- .mat-mdc-list-avatar ~ .mat-divider-inset {
+ .mat-mdc-list-item-avatar ~ .mat-divider-inset {
margin-left: 72px;
[dir='rtl'] & {
@@ -60,23 +60,23 @@
pointer-events: none;
}
-// If there are projected lines, we project any remaining content into the list-item's end
-// container and set the `--with-trailing-meta` class. If this container is empty due to no
-// projected content though, we want to hide the element because otherwise the empty container
-// would take up horizontal space. There is no good way to check for remaining projected content
-// in the template, so we just use CSS with the `:empty` selector. Note that we need increased
-// specificity here because MDC overrides the `display` with the `--trailing-meta` class too.
-.mat-mdc-list-item.mdc-list-item--with-trailing-meta > .mdc-list-item__end:empty {
- display: none;
-}
+.mat-mdc-list-item.mdc-list-item--with-three-lines {
+ // List item lines or titles never wrap. MDC always enables wrapping for secondary text
+ // if the list item has acquired three lines. We unset these styles for line elements.
+ // https://github.com/material-components/material-components-web/blob/348665978ce73694ad4518626dd70cdf5b984113/packages/mdc-list/_evolution-mixins.scss#L205-L206.
+ // TODO: Consider removing once MDC supports the explicit tertiary line list variant.
+ .mat-mdc-list-item-line.mdc-list-item__secondary-text {
+ white-space: nowrap;
+ line-height: normal;
+ }
-// Unset MDC's styles for wrapping secondary text in three-line lists. MDC implements three-line
-// lists in a way where they assume that the second list line should wrap into the third line.
-// This is different to the three-line list variant we want to support. We support a dedicated
-// third line that can be controlled by consumers using a third `matLine` span.
-// https://github.com/material-components/material-components-web/blob/master/packages/mdc-list/_evolution-mixins.scss#L199.
-// TODO: Consider removing once MDC supports the various three-line list variants.
-.mat-mdc-list-item.mdc-list-item--with-three-lines .mdc-list-item__secondary-text {
- white-space: nowrap;
- line-height: normal;
+ // Unscoped content can wrap if the list item has acquired three lines. MDC implements
+ // this functionality for secondary text but there is no proper text ellipsis when
+ // text overflows the third line. These styles ensure the overflow is handled properly.
+ // TODO: Move this to the the MDC list once it drops IE11 support.
+ .mat-mdc-list-item-unscoped-content.mdc-list-item__secondary-text {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ }
}
diff --git a/src/material-experimental/mdc-list/list.spec.ts b/src/material-experimental/mdc-list/list.spec.ts
index 12c62b7f1a0c..6fe496a663ec 100644
--- a/src/material-experimental/mdc-list/list.spec.ts
+++ b/src/material-experimental/mdc-list/list.spec.ts
@@ -26,7 +26,6 @@ describe('MDC-based MatList', () => {
NavListWithOneAnchorItem,
ActionListWithoutType,
ActionListWithType,
- ListWithIndirectDescendantLines,
ListWithDisabledItems,
],
});
@@ -41,7 +40,6 @@ describe('MDC-based MatList', () => {
fixture.detectChanges();
expect(listItem.nativeElement.classList).toContain('mat-mdc-list-item');
expect(listItem.nativeElement.classList).toContain('mdc-list-item');
- expect(listItem.nativeElement.classList).toContain('mat-mdc-list-item-single-line');
expect(listItem.nativeElement.classList).toContain('mdc-list-item--with-one-line');
});
@@ -50,8 +48,7 @@ describe('MDC-based MatList', () => {
fixture.detectChanges();
const listItems = fixture.debugElement.children[0].queryAll(By.css('mat-list-item'));
- expect(listItems[0].nativeElement.className).toContain('mat-mdc-2-line');
- expect(listItems[1].nativeElement.className).toContain('mat-mdc-2-line');
+ expect(listItems[0].nativeElement.className).toContain('mdc-list-item--with-two-lines');
});
it('should apply a particular class to lists with three lines', () => {
@@ -59,17 +56,7 @@ describe('MDC-based MatList', () => {
fixture.detectChanges();
const listItems = fixture.debugElement.children[0].queryAll(By.css('mat-list-item'));
- expect(listItems[0].nativeElement.className).toContain('mat-mdc-3-line');
- expect(listItems[1].nativeElement.className).toContain('mat-mdc-3-line');
- });
-
- it('should apply a particular class to lists with more than 3 lines', () => {
- const fixture = TestBed.createComponent(ListWithManyLines);
- fixture.detectChanges();
-
- const listItems = fixture.debugElement.children[0].queryAll(By.css('mat-list-item'));
- expect(listItems[0].nativeElement.className).toContain('mat-mdc-multi-line');
- expect(listItems[1].nativeElement.className).toContain('mat-mdc-multi-line');
+ expect(listItems[0].nativeElement.className).toContain('mdc-list-item--with-three-lines');
});
it('should apply a class to list items with avatars', () => {
@@ -110,13 +97,11 @@ describe('MDC-based MatList', () => {
const listItem = fixture.debugElement.children[0].query(By.css('mat-list-item'))!;
expect(listItem.nativeElement.classList).toContain('mdc-list-item--with-two-lines');
- expect(listItem.nativeElement.classList).toContain('mat-mdc-2-line');
expect(listItem.nativeElement.classList).toContain('mat-mdc-list-item');
expect(listItem.nativeElement.classList).toContain('mdc-list-item');
fixture.debugElement.componentInstance.showThirdLine = true;
fixture.detectChanges();
- expect(listItem.nativeElement.className).toContain('mat-mdc-3-line');
expect(listItem.nativeElement.className).toContain('mdc-list-item--with-three-lines');
});
@@ -309,15 +294,6 @@ describe('MDC-based MatList', () => {
.toBe(0);
}));
- it('should pick up indirect descendant lines', () => {
- const fixture = TestBed.createComponent(ListWithIndirectDescendantLines);
- fixture.detectChanges();
-
- const listItems = fixture.debugElement.children[0].queryAll(By.css('mat-list-item'));
- expect(listItems[0].nativeElement.className).toContain('mat-mdc-2-line');
- expect(listItems[1].nativeElement.className).toContain('mat-mdc-2-line');
- });
-
it('should be able to disable a single list item', () => {
const fixture = TestBed.createComponent(ListWithDisabledItems);
const listItems: HTMLElement[] = Array.from(
@@ -435,8 +411,8 @@ class ListWithOneItem extends BaseTestList {}
-
`,
})
@@ -514,20 +490,6 @@ class ListWithDynamicNumberOfLines extends BaseTestList {}
})
class ListWithMultipleItems extends BaseTestList {}
-// Note the blank `ngSwitch` which we need in order to hit the bug that we're testing.
-@Component({
- template: `
-
-
-
-
{{item.name}}
-
{{item.description}}
-
-
- `,
-})
-class ListWithIndirectDescendantLines extends BaseTestList {}
-
@Component({
template: `
diff --git a/src/material-experimental/mdc-list/list.ts b/src/material-experimental/mdc-list/list.ts
index f1692ecbac8e..58643391d646 100644
--- a/src/material-experimental/mdc-list/list.ts
+++ b/src/material-experimental/mdc-list/list.ts
@@ -20,12 +20,12 @@ import {
ViewEncapsulation,
} from '@angular/core';
import {
- MatLine,
MAT_RIPPLE_GLOBAL_OPTIONS,
RippleGlobalOptions,
} from '@angular/material-experimental/mdc-core';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
import {MatListBase, MatListItemBase} from './list-base';
+import {MatListItemLine, MatListItemMeta, MatListItemTitle} from './list-item-sections';
@Component({
selector: 'mat-list',
@@ -48,11 +48,7 @@ export class MatList extends MatListBase {}
'class': 'mat-mdc-list-item mdc-list-item',
'[class.mdc-list-item--with-leading-avatar]': '_avatars.length !== 0',
'[class.mdc-list-item--with-leading-icon]': '_icons.length !== 0',
- // If there are projected lines, we project the remaining content into the `mdc-list-item__end`
- // container. In order to make sure the container aligns properly (if there is content), we add
- // the trailing meta class. Note that we also add this even if there is no projected `meta`
- // content. This is because there is no good way to check for remaining projected content.
- '[class.mdc-list-item--with-trailing-meta]': 'lines.length !== 0',
+ '[class.mdc-list-item--with-trailing-meta]': '_meta.length !== 0',
'[class._mat-animation-noopable]': '_noopAnimations',
},
templateUrl: 'list-item.html',
@@ -60,9 +56,10 @@ export class MatList extends MatListBase {}
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatListItem extends MatListItemBase {
- @ContentChildren(MatLine, {read: ElementRef, descendants: true}) lines: QueryList<
- ElementRef
- >;
+ @ContentChildren(MatListItemLine, {descendants: true}) _lines: QueryList;
+ @ContentChildren(MatListItemTitle, {descendants: true}) _titles: QueryList;
+ @ContentChildren(MatListItemMeta, {descendants: true}) _meta: QueryList;
+ @ViewChild('unscopedContent') _unscopedContent: ElementRef;
@ViewChild('text') _itemText: ElementRef;
constructor(
diff --git a/src/material-experimental/mdc-list/module.ts b/src/material-experimental/mdc-list/module.ts
index 878d43adcb5a..c0af078cf855 100644
--- a/src/material-experimental/mdc-list/module.ts
+++ b/src/material-experimental/mdc-list/module.ts
@@ -9,7 +9,6 @@
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {
- MatLineModule,
MatPseudoCheckboxModule,
MatRippleModule,
MatCommonModule,
@@ -18,17 +17,26 @@ import {MatDividerModule} from '@angular/material/divider';
import {MatActionList} from './action-list';
import {MatList, MatListItem} from './list';
import {MatListOption} from './list-option';
+import {MatListSubheaderCssMatStyler} from './subheader';
import {
- MatListAvatarCssMatStyler,
- MatListGraphicAlignmentStyler,
- MatListIconCssMatStyler,
- MatListSubheaderCssMatStyler,
-} from './list-styling';
+ MatListItemLine,
+ MatListItemTitle,
+ MatListItemMeta,
+ MatListItemAvatar,
+ MatListItemIcon,
+} from './list-item-sections';
import {MatNavList} from './nav-list';
import {MatSelectionList} from './selection-list';
+import {ObserversModule} from '@angular/cdk/observers';
@NgModule({
- imports: [CommonModule, MatCommonModule, MatLineModule, MatRippleModule, MatPseudoCheckboxModule],
+ imports: [
+ ObserversModule,
+ CommonModule,
+ MatCommonModule,
+ MatRippleModule,
+ MatPseudoCheckboxModule,
+ ],
exports: [
MatList,
MatActionList,
@@ -36,12 +44,13 @@ import {MatSelectionList} from './selection-list';
MatSelectionList,
MatListItem,
MatListOption,
- MatListAvatarCssMatStyler,
- MatListIconCssMatStyler,
+ MatListItemAvatar,
+ MatListItemIcon,
MatListSubheaderCssMatStyler,
- MatListGraphicAlignmentStyler,
MatDividerModule,
- MatLineModule,
+ MatListItemLine,
+ MatListItemTitle,
+ MatListItemMeta,
],
declarations: [
MatList,
@@ -50,10 +59,12 @@ import {MatSelectionList} from './selection-list';
MatSelectionList,
MatListItem,
MatListOption,
- MatListAvatarCssMatStyler,
- MatListIconCssMatStyler,
MatListSubheaderCssMatStyler,
- MatListGraphicAlignmentStyler,
+ MatListItemAvatar,
+ MatListItemIcon,
+ MatListItemLine,
+ MatListItemTitle,
+ MatListItemMeta,
],
})
export class MatListModule {}
diff --git a/src/material-experimental/mdc-list/public-api.ts b/src/material-experimental/mdc-list/public-api.ts
index 37d7928d805b..adc02bd749df 100644
--- a/src/material-experimental/mdc-list/public-api.ts
+++ b/src/material-experimental/mdc-list/public-api.ts
@@ -12,7 +12,9 @@ export * from './module';
export * from './nav-list';
export * from './selection-list';
export * from './list-option';
-export * from './list-styling';
+export * from './subheader';
+export * from './list-item-sections';
+
export {MatListOptionCheckboxPosition} from './list-option-types';
export {MatListOption} from './list-option';
diff --git a/src/material-experimental/mdc-list/selection-list.spec.ts b/src/material-experimental/mdc-list/selection-list.spec.ts
index 3bde5c3da64f..9ee28517e1ed 100644
--- a/src/material-experimental/mdc-list/selection-list.spec.ts
+++ b/src/material-experimental/mdc-list/selection-list.spec.ts
@@ -50,7 +50,6 @@ describe('MDC-based MatSelectionList without forms', () => {
SelectionListWithOnlyOneOption,
SelectionListWithIndirectChildOptions,
SelectionListWithSelectedOptionAndValue,
- SelectionListWithIndirectDescendantLines,
],
});
@@ -361,6 +360,94 @@ describe('MDC-based MatSelectionList without forms', () => {
expect(event.defaultPrevented).toBe(true);
});
+ it('should select all items using ctrl + a', () => {
+ listOptions.forEach(option => (option.componentInstance.disabled = false));
+ fixture.detectChanges();
+
+ expect(listOptions.some(option => option.componentInstance.selected)).toBe(false);
+
+ listOptions[2].nativeElement.focus();
+ dispatchKeyboardEvent(listOptions[2].nativeElement, 'keydown', A, 'A', {control: true});
+ fixture.detectChanges();
+
+ expect(listOptions.every(option => option.componentInstance.selected)).toBe(true);
+ });
+
+ it('should not select disabled items when pressing ctrl + a', () => {
+ listOptions.slice(0, 2).forEach(option => (option.componentInstance.disabled = true));
+ fixture.detectChanges();
+
+ expect(listOptions.map(option => option.componentInstance.selected)).toEqual([
+ false,
+ false,
+ false,
+ false,
+ false,
+ ]);
+
+ listOptions[3].nativeElement.focus();
+ dispatchKeyboardEvent(listOptions[3].nativeElement, 'keydown', A, 'A', {control: true});
+ fixture.detectChanges();
+
+ expect(listOptions.map(option => option.componentInstance.selected)).toEqual([
+ false,
+ false,
+ true,
+ true,
+ true,
+ ]);
+ });
+
+ it('should select all items using ctrl + a if some items are selected', () => {
+ listOptions.slice(0, 2).forEach(option => (option.componentInstance.selected = true));
+ fixture.detectChanges();
+
+ expect(listOptions.some(option => option.componentInstance.selected)).toBe(true);
+
+ listOptions[2].nativeElement.focus();
+ dispatchKeyboardEvent(listOptions[2].nativeElement, 'keydown', A, 'A', {control: true});
+ fixture.detectChanges();
+
+ expect(listOptions.every(option => option.componentInstance.selected)).toBe(true);
+ });
+
+ it('should deselect all with ctrl + a if all options are selected', () => {
+ listOptions.forEach(option => (option.componentInstance.selected = true));
+ fixture.detectChanges();
+
+ expect(listOptions.every(option => option.componentInstance.selected)).toBe(true);
+
+ listOptions[2].nativeElement.focus();
+ dispatchKeyboardEvent(listOptions[2].nativeElement, 'keydown', A, 'A', {control: true});
+ fixture.detectChanges();
+
+ expect(listOptions.every(option => option.componentInstance.selected)).toBe(false);
+ });
+
+ // This is temporarily disabled as the MDC list does not emit a proper event when
+ // items are interactively toggled with e.g. `CTRL + A`.
+ // TODO(devversion): look more into this. MDC does not expose an `onChange` adapter
+ // function. Authors are required to emit a change event on checkbox/radio change, but
+ // that is not an viable option for us since we also allow for programmatic selection updates.
+ // https://github.com/material-components/material-components-web/blob/a986df922b6b4c1ef5c59925107281d1d40287a8/packages/mdc-list/component.ts#L300-L308.
+ // tslint:disable-next-line:ban
+ xit('should dispatch the selectionChange event when selecting via ctrl + a', () => {
+ const spy = spyOn(fixture.componentInstance, 'onSelectionChange');
+ listOptions.forEach(option => (option.componentInstance.disabled = false));
+ fixture.detectChanges();
+
+ listOptions[2].nativeElement.focus();
+ dispatchKeyboardEvent(listOptions[2].nativeElement, 'keydown', A, 'A', {control: true});
+ fixture.detectChanges();
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ options: listOptions.map(option => option.componentInstance),
+ }),
+ );
+ });
+
it('should be able to jump focus down to an item by typing', fakeAsync(() => {
const firstOption = listOptions[0].nativeElement;
@@ -566,14 +653,6 @@ describe('MDC-based MatSelectionList without forms', () => {
expect(listItemEl.componentInstance.value).toBe(componentFixture.componentInstance.itemValue);
});
- it('should pick up indirect descendant lines', () => {
- const componentFixture = TestBed.createComponent(SelectionListWithIndirectDescendantLines);
- componentFixture.detectChanges();
-
- const option = componentFixture.nativeElement.querySelector('mat-list-option');
- expect(option.classList).toContain('mat-mdc-2-line');
- });
-
it('should have a focus indicator', () => {
const optionNativeElements = listOptions.map(option => option.nativeElement as HTMLElement);
@@ -814,7 +893,7 @@ describe('MDC-based MatSelectionList without forms', () => {
* ensures no avatar is shown at the specified position.
*/
function expectIconAt(item: HTMLElement, position: 'before' | 'after') {
- const icon = item.querySelector('.mat-mdc-list-icon')!;
+ const icon = item.querySelector('.mat-mdc-list-item-icon')!;
expect(item.classList).not.toContain('mdc-list-item--with-leading-avatar');
expect(item.classList).not.toContain('mat-mdc-list-option-with-trailing-avatar');
@@ -835,7 +914,7 @@ describe('MDC-based MatSelectionList without forms', () => {
* ensures that no icon is shown at the specified position.
*/
function expectAvatarAt(item: HTMLElement, position: 'before' | 'after') {
- const avatar = item.querySelector('.mat-mdc-list-avatar')!;
+ const avatar = item.querySelector('.mat-mdc-list-item-avatar')!;
expect(item.classList).not.toContain('mdc-list-item--with-leading-icon');
expect(item.classList).not.toContain('mdc-list-item--with-trailing-icon');
@@ -1733,7 +1812,7 @@ class SelectionListWithCustomComparator {
template: `
-