Skip to content

feat(material-experimental/mdc-list): add MDC foundation for action/nav list #19601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 19, 2020
4 changes: 2 additions & 2 deletions src/material-experimental/mdc-list/action-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {MatListBase} from './list-base';
import {MatInteractiveListBase, MatListBase} from './list-base';

@Component({
selector: 'mat-action-list',
Expand All @@ -23,4 +23,4 @@ import {MatListBase} from './list-base';
{provide: MatListBase, useExisting: MatActionList},
]
})
export class MatActionList extends MatListBase {}
export class MatActionList extends MatInteractiveListBase {}
175 changes: 151 additions & 24 deletions src/material-experimental/mdc-list/list-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@
*/

import {Platform} from '@angular/cdk/platform';
import {DOCUMENT} from '@angular/common';
import {
AfterContentInit,
AfterViewInit,
ContentChildren,
Directive,
ElementRef,
HostBinding,
HostListener,
Inject,
NgZone,
OnDestroy,
QueryList
} from '@angular/core';
import {RippleConfig, RippleRenderer, RippleTarget, setLines} from '@angular/material/core';
import {MDCListAdapter, MDCListFoundation} from '@material/list';
import {Subscription} from 'rxjs';
import {startWith} from 'rxjs/operators';

Expand All @@ -28,17 +34,6 @@ function toggleClass(el: Element, className: string, on: boolean) {
}
}

@Directive()
/** @docs-private */
export abstract class MatListBase {
// @HostBinding is used in the class as it is expected to be extended. Since @Component decorator
// metadata is not inherited by child classes, instead the host binding data is defined in a way
// that can be inherited.
// tslint:disable-next-line:no-host-decorator-in-concrete
@HostBinding('class.mdc-list--non-interactive')
_isNonInteractive: boolean = false;
}

@Directive()
/** @docs-private */
export abstract class MatListItemBase implements AfterContentInit, OnDestroy, RippleTarget {
Expand All @@ -53,22 +48,37 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri

private _rippleRenderer: RippleRenderer;

constructor(protected _element: ElementRef, protected _ngZone: NgZone, listBase: MatListBase,
platform: Platform) {
const el = this._element.nativeElement;
this.rippleDisabled = listBase._isNonInteractive;
if (!listBase._isNonInteractive) {
el.classList.add('mat-mdc-list-item-interactive');
}
this._rippleRenderer =
new RippleRenderer(this, this._ngZone, el, platform);
this._rippleRenderer.setupTriggerEvents(el);
protected constructor(public _elementRef: ElementRef<HTMLElement>, protected _ngZone: NgZone,
private _listBase: MatListBase, private _platform: Platform) {
this._initRipple();
}

ngAfterContentInit() {
this._monitorLines();
}

ngOnDestroy() {
this._subscriptions.unsubscribe();
this._rippleRenderer._removeTriggerEvents();
}

_initDefaultTabIndex(tabIndex: number) {
const el = this._elementRef.nativeElement;
if (!el.hasAttribute('tabIndex')) {
el.tabIndex = tabIndex;
}
}

private _initRipple() {
this.rippleDisabled = this._listBase._isNonInteractive;
if (!this._listBase._isNonInteractive) {
this._elementRef.nativeElement.classList.add('mat-mdc-list-item-interactive');
}
this._rippleRenderer =
new RippleRenderer(this, this._ngZone, this._elementRef.nativeElement, this._platform);
this._rippleRenderer.setupTriggerEvents(this._elementRef.nativeElement);
}

/**
* Subscribes to changes in `MatLine` content children and annotates them appropriately when they
* change.
Expand All @@ -77,20 +87,137 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
this._ngZone.runOutsideAngular(() => {
this._subscriptions.add(this.lines.changes.pipe(startWith(this.lines))
.subscribe((lines: QueryList<ElementRef<Element>>) => {
this._element.nativeElement.classList
this._elementRef.nativeElement.classList
.toggle('mat-mdc-list-item-single-line', lines.length <= 1);
lines.forEach((line: ElementRef<Element>, index: number) => {
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._element, 'mat-mdc');
setLines(lines, this._elementRef, 'mat-mdc');
}));
});
}
}

@Directive()
/** @docs-private */
export abstract class MatListBase {
@HostBinding('class.mdc-list--non-interactive')
_isNonInteractive: boolean = true;
}

@Directive()
export abstract class MatInteractiveListBase extends MatListBase
implements AfterViewInit, OnDestroy {
@HostListener('keydown', ['$event'])
_handleKeydown(event: KeyboardEvent) {
const index = this._indexForElement(event.target as HTMLElement);
this._foundation.handleKeydown(
event, this._elementAtIndex(index) === event.target, index);
}

@HostListener('click', ['$event'])
_handleClick(event: MouseEvent) {
this._foundation.handleClick(this._indexForElement(event.target as HTMLElement), false);
}

@HostListener('focusin', ['$event'])
_handleFocusin(event: FocusEvent) {
this._foundation.handleFocusIn(event, this._indexForElement(event.target as HTMLElement));
}

@HostListener('focusout', ['$event'])
_handleFocusout(event: FocusEvent) {
this._foundation.handleFocusOut(event, this._indexForElement(event.target as HTMLElement));
}

@ContentChildren(MatListItemBase, {descendants: true}) _items: QueryList<MatListItemBase>;

protected _adapter: MDCListAdapter = {
getListItemCount: () => this._items.length,
listItemAtIndexHasClass:
(index, className) => this._elementAtIndex(index).classList.contains(className),
addClassForElementIndex:
(index, className) => this._elementAtIndex(index).classList.add(className),
removeClassForElementIndex:
(index, className) => this._elementAtIndex(index).classList.remove(className),
getAttributeForElementIndex: (index, attr) => this._elementAtIndex(index).getAttribute(attr),
setAttributeForElementIndex:
(index, attr, value) => this._elementAtIndex(index).setAttribute(attr, value),
getFocusedElementIndex: () => this._indexForElement(this._document?.activeElement),
isFocusInsideList: () => this._element.nativeElement.contains(this._document?.activeElement),
isRootFocused: () => this._element.nativeElement === this._document?.activeElement,
focusItemAtIndex: index => this._elementAtIndex(index).focus(),

// MDC uses this method to disable focusable children of list items. However, we believe that
// this is not an accessible pattern and should be avoided, therefore we intentionally do not
// implement this method. In addition, implementing this would require violating Angular
// Material's general principle of not having components modify DOM elements they do not own.
// A user who feels they really need this feature can simply listen to the `(focus)` and
// `(blur)` events on the list item and enable/disable focus on the children themselves as
// appropriate.
setTabIndexForListItemChildren: () => {},

// The following methods have a dummy implementation in the base class because they are only
// applicable to certain types of lists. They should be implemented for the concrete classes
// where they are applicable.
hasCheckboxAtIndex: () => false,
hasRadioAtIndex: () => false,
setCheckedCheckboxOrRadioAtIndex: () => {},
isCheckboxCheckedAtIndex: () => false,

// TODO(mmalerba): Determine if we need to implement these.
getPrimaryTextAtIndex: () => '',
notifyAction: () => {},
};

protected _foundation: MDCListFoundation;

protected _document: Document;

private _itemsArr: MatListItemBase[] = [];

private _subscriptions = new Subscription();

constructor(protected _element: ElementRef<HTMLElement>, @Inject(DOCUMENT) document: any) {
super();
this._document = document;
this._isNonInteractive = false;
this._foundation = new MDCListFoundation(this._adapter);
}

ngAfterViewInit() {
this._initItems();
this._foundation.init();
this._foundation.layout();
}

ngOnDestroy() {
this._foundation.destroy();
this._subscriptions.unsubscribe();
this._rippleRenderer._removeTriggerEvents();
}

private _initItems() {
this._subscriptions.add(
this._items.changes.pipe(startWith(null))
.subscribe(() => this._itemsArr = this._items.toArray()));
for (let i = 0; this._itemsArr.length; i++) {
this._itemsArr[i]._initDefaultTabIndex(i === 0 ? 0 : -1);
}
}

private _itemAtIndex(index: number): MatListItemBase {
return this._itemsArr[index];
}

private _elementAtIndex(index: number): HTMLElement {
return this._itemAtIndex(index)._elementRef.nativeElement;
}

private _indexForElement(element: Element | null) {
return element ?
this._itemsArr.findIndex(i => i._elementRef.nativeElement.contains(element)) : -1;
}
}

7 changes: 4 additions & 3 deletions src/material-experimental/mdc-list/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ export class MatListSubheaderCssMatStyler {}
{provide: MatListBase, useExisting: MatList},
]
})
export class MatList extends MatListBase {
_isNonInteractive = true;
}
export class MatList extends MatListBase {}

@Component({
selector: 'mat-list-item, a[mat-list-item], button[mat-list-item]',
Expand All @@ -79,6 +77,9 @@ export class MatList extends MatListBase {
templateUrl: 'list-item.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{provide: MatListItemBase, useExisting: MatListItem},
]
})
export class MatListItem extends MatListItemBase {
@ContentChildren(MatLine, {read: ElementRef, descendants: true}) lines:
Expand Down
4 changes: 2 additions & 2 deletions src/material-experimental/mdc-list/nav-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {MatList} from './list';
import {MatListBase} from './list-base';
import {MatInteractiveListBase, MatListBase} from './list-base';

@Component({
selector: 'mat-nav-list',
Expand All @@ -33,4 +33,4 @@ import {MatListBase} from './list-base';
{provide: MatList, useExisting: MatNavList},
]
})
export class MatNavList extends MatListBase {}
export class MatNavList extends MatInteractiveListBase {}
3 changes: 3 additions & 0 deletions src/material-experimental/mdc-list/selection-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export class MatSelectionList extends MatListBase implements ControlValueAccesso
templateUrl: 'list-option.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{provide: MatListItemBase, useExisting: MatListOption},
]
})
export class MatListOption extends MatListItemBase {
static ngAcceptInputType_disabled: BooleanInput;
Expand Down