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 @@

mat-list demo

- + + + +
-

Normal lists

@@ -16,18 +18,18 @@

Normal lists

-
{{contact.name}}
-
extra line
-
{{contact.headline}}
+
{{contact.name}}
+
extra line
+
{{contact.headline}}
Today
- Image of {{message.from}} -
{{message.from}}
-
+ Image of {{message.from}} +
{{message.from}}
+
{{message.subject}} -- {{message.message}}
@@ -35,16 +37,16 @@

Normal lists

-
{{message.from}}
-
{{message.subject}}
-
{{message.message}}
+
{{message.from}}
+
{{message.subject}}
+
{{message.message}}
- {{ link.name }} - @@ -62,25 +64,25 @@

Dense lists

-
{{contact.name}}
-
{{contact.headline}}
+
{{contact.name}}
+
{{contact.headline}}
Today
- Image of {{message.from}} -
{{message.from}}
-
{{message.subject}}
-
{{message.message}}
+ Image of {{message.from}} +
{{message.from}}
+
{{message.subject}}
+
{{message.message}}
- {{ link.name }} - @@ -98,8 +100,8 @@

Nav lists

- folder - {{ link.name }} + folder + {{ link.name }}
@@ -134,13 +136,13 @@

Selection list

Dogs
- - Shiba Inu + + Shiba Inu - - Other Shiba Inu + + Other Shiba Inu @@ -183,21 +185,52 @@

Single Selection list

Selected: {{favoriteOptions | json}}

+
+

Line scenarios

+ + + 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 + + + + +
+

Line alignment

- {{ link.name }} - Not in an matLine + {{ link.name }} + Unscoped content First - 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 {} -

{{item.name}}

-

{{item.description}}

+

{{item.name}}

+

{{item.description}}

`, }) @@ -446,9 +422,9 @@ class ListWithTwoLineItem extends BaseTestList {} template: ` -

{{item.name}}

-

{{item.description}}

-

Some other text

+

{{item.name}}

+

{{item.description}}

+

Some other text

`, }) @@ -458,10 +434,10 @@ class ListWithThreeLineItem extends BaseTestList {} template: ` -

Line 1

-

Line 2

-

Line 3

-

Line 4

+

Line 1

+

Line 2

+

Line 3

+

Line 4

`, }) @@ -471,7 +447,7 @@ class ListWithManyLines extends BaseTestList {} template: ` - + Paprika @@ -485,8 +461,8 @@ class ListWithAvatar extends BaseTestList {} template: ` -

{{item.name}}

-

{{item.description}}

+

{{item.name}}

+

{{item.description}}

`, }) @@ -496,9 +472,9 @@ class ListWithItemWithCssClass extends BaseTestList {} template: ` -

{{item.name}}

-

{{item.description}}

-

Some other text

+

{{item.name}}

+

{{item.description}}

+

Some other text

`, }) @@ -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: ` -
I
+
I
Inbox
@@ -1747,7 +1826,7 @@ class SelectionListWithAvatar { template: ` -
I
+
I
Inbox
@@ -1771,20 +1850,6 @@ class SelectionListWithIndirectChildOptions { @ViewChildren(MatListOption) optionInstances: QueryList; } -// Note the blank `ngSwitch` which we need in order to hit the bug that we're testing. -@Component({ - template: ` - - - -

Item

-

Item description

-
-
-
`, -}) -class SelectionListWithIndirectDescendantLines {} - @Component({ template: ` diff --git a/src/material-experimental/mdc-list/subheader.ts b/src/material-experimental/mdc-list/subheader.ts new file mode 100644 index 000000000000..b1fbfb21da2e --- /dev/null +++ b/src/material-experimental/mdc-list/subheader.ts @@ -0,0 +1,21 @@ +/** + * @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} from '@angular/core'; + +/** + * 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 {}