diff --git a/src/material-experimental/mdc-list/action-list.ts b/src/material-experimental/mdc-list/action-list.ts index b7df2137b024..0a4e9ad05848 100644 --- a/src/material-experimental/mdc-list/action-list.ts +++ b/src/material-experimental/mdc-list/action-list.ts @@ -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', @@ -23,4 +23,4 @@ import {MatListBase} from './list-base'; {provide: MatListBase, useExisting: MatActionList}, ] }) -export class MatActionList extends MatListBase {} +export class MatActionList extends MatInteractiveListBase {} diff --git a/src/material-experimental/mdc-list/list-base.ts b/src/material-experimental/mdc-list/list-base.ts index 090043279999..898f470e9f82 100644 --- a/src/material-experimental/mdc-list/list-base.ts +++ b/src/material-experimental/mdc-list/list-base.ts @@ -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'; @@ -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 { @@ -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, 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. @@ -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>) => { - this._element.nativeElement.classList + this._elementRef.nativeElement.classList .toggle('mat-mdc-list-item-single-line', lines.length <= 1); lines.forEach((line: ElementRef, 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; + + 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, @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; } } + diff --git a/src/material-experimental/mdc-list/list.ts b/src/material-experimental/mdc-list/list.ts index 36396665a861..b49250c113c8 100644 --- a/src/material-experimental/mdc-list/list.ts +++ b/src/material-experimental/mdc-list/list.ts @@ -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]', @@ -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: diff --git a/src/material-experimental/mdc-list/nav-list.ts b/src/material-experimental/mdc-list/nav-list.ts index 28b4dd07cd19..7d22950b8ff9 100644 --- a/src/material-experimental/mdc-list/nav-list.ts +++ b/src/material-experimental/mdc-list/nav-list.ts @@ -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', @@ -33,4 +33,4 @@ import {MatListBase} from './list-base'; {provide: MatList, useExisting: MatNavList}, ] }) -export class MatNavList extends MatListBase {} +export class MatNavList extends MatInteractiveListBase {} diff --git a/src/material-experimental/mdc-list/selection-list.ts b/src/material-experimental/mdc-list/selection-list.ts index c6f464b8ecd7..65b11ec00ba6 100644 --- a/src/material-experimental/mdc-list/selection-list.ts +++ b/src/material-experimental/mdc-list/selection-list.ts @@ -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;