Skip to content

Commit 166bb52

Browse files
authored
feat(material-experimental/mdc-list): add MDC foundation for action/nav list (#19601)
* feat(material-expeirmental/mdc-list): add support for focus/hover states and ripples * add state styles * add adapter for MDCList * set up MDCListFoundation * refactor so only interactive lists set up the foundation * address feedback * use descendants:true for content children * don't change tabindex on child elements of list-item * fix tabIndex initialization * move logic out of lifecycle hooks
1 parent 33aeea9 commit 166bb52

File tree

5 files changed

+162
-31
lines changed

5 files changed

+162
-31
lines changed

src/material-experimental/mdc-list/action-list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

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

1212
@Component({
1313
selector: 'mat-action-list',
@@ -23,4 +23,4 @@ import {MatListBase} from './list-base';
2323
{provide: MatListBase, useExisting: MatActionList},
2424
]
2525
})
26-
export class MatActionList extends MatListBase {}
26+
export class MatActionList extends MatInteractiveListBase {}

src/material-experimental/mdc-list/list-base.ts

Lines changed: 151 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@
77
*/
88

99
import {Platform} from '@angular/cdk/platform';
10+
import {DOCUMENT} from '@angular/common';
1011
import {
1112
AfterContentInit,
13+
AfterViewInit,
14+
ContentChildren,
1215
Directive,
1316
ElementRef,
1417
HostBinding,
18+
HostListener,
19+
Inject,
1520
NgZone,
1621
OnDestroy,
1722
QueryList
1823
} from '@angular/core';
1924
import {RippleConfig, RippleRenderer, RippleTarget, setLines} from '@angular/material/core';
25+
import {MDCListAdapter, MDCListFoundation} from '@material/list';
2026
import {Subscription} from 'rxjs';
2127
import {startWith} from 'rxjs/operators';
2228

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

31-
@Directive()
32-
/** @docs-private */
33-
export abstract class MatListBase {
34-
// @HostBinding is used in the class as it is expected to be extended. Since @Component decorator
35-
// metadata is not inherited by child classes, instead the host binding data is defined in a way
36-
// that can be inherited.
37-
// tslint:disable-next-line:no-host-decorator-in-concrete
38-
@HostBinding('class.mdc-list--non-interactive')
39-
_isNonInteractive: boolean = false;
40-
}
41-
4237
@Directive()
4338
/** @docs-private */
4439
export abstract class MatListItemBase implements AfterContentInit, OnDestroy, RippleTarget {
@@ -53,22 +48,37 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
5348

5449
private _rippleRenderer: RippleRenderer;
5550

56-
constructor(protected _element: ElementRef, protected _ngZone: NgZone, listBase: MatListBase,
57-
platform: Platform) {
58-
const el = this._element.nativeElement;
59-
this.rippleDisabled = listBase._isNonInteractive;
60-
if (!listBase._isNonInteractive) {
61-
el.classList.add('mat-mdc-list-item-interactive');
62-
}
63-
this._rippleRenderer =
64-
new RippleRenderer(this, this._ngZone, el, platform);
65-
this._rippleRenderer.setupTriggerEvents(el);
51+
protected constructor(public _elementRef: ElementRef<HTMLElement>, protected _ngZone: NgZone,
52+
private _listBase: MatListBase, private _platform: Platform) {
53+
this._initRipple();
6654
}
6755

6856
ngAfterContentInit() {
6957
this._monitorLines();
7058
}
7159

60+
ngOnDestroy() {
61+
this._subscriptions.unsubscribe();
62+
this._rippleRenderer._removeTriggerEvents();
63+
}
64+
65+
_initDefaultTabIndex(tabIndex: number) {
66+
const el = this._elementRef.nativeElement;
67+
if (!el.hasAttribute('tabIndex')) {
68+
el.tabIndex = tabIndex;
69+
}
70+
}
71+
72+
private _initRipple() {
73+
this.rippleDisabled = this._listBase._isNonInteractive;
74+
if (!this._listBase._isNonInteractive) {
75+
this._elementRef.nativeElement.classList.add('mat-mdc-list-item-interactive');
76+
}
77+
this._rippleRenderer =
78+
new RippleRenderer(this, this._ngZone, this._elementRef.nativeElement, this._platform);
79+
this._rippleRenderer.setupTriggerEvents(this._elementRef.nativeElement);
80+
}
81+
7282
/**
7383
* Subscribes to changes in `MatLine` content children and annotates them appropriately when they
7484
* change.
@@ -77,20 +87,137 @@ export abstract class MatListItemBase implements AfterContentInit, OnDestroy, Ri
7787
this._ngZone.runOutsideAngular(() => {
7888
this._subscriptions.add(this.lines.changes.pipe(startWith(this.lines))
7989
.subscribe((lines: QueryList<ElementRef<Element>>) => {
80-
this._element.nativeElement.classList
90+
this._elementRef.nativeElement.classList
8191
.toggle('mat-mdc-list-item-single-line', lines.length <= 1);
8292
lines.forEach((line: ElementRef<Element>, index: number) => {
8393
toggleClass(line.nativeElement,
8494
'mdc-list-item__primary-text', index === 0 && lines.length > 1);
8595
toggleClass(line.nativeElement, 'mdc-list-item__secondary-text', index !== 0);
8696
});
87-
setLines(lines, this._element, 'mat-mdc');
97+
setLines(lines, this._elementRef, 'mat-mdc');
8898
}));
8999
});
90100
}
101+
}
102+
103+
@Directive()
104+
/** @docs-private */
105+
export abstract class MatListBase {
106+
@HostBinding('class.mdc-list--non-interactive')
107+
_isNonInteractive: boolean = true;
108+
}
109+
110+
@Directive()
111+
export abstract class MatInteractiveListBase extends MatListBase
112+
implements AfterViewInit, OnDestroy {
113+
@HostListener('keydown', ['$event'])
114+
_handleKeydown(event: KeyboardEvent) {
115+
const index = this._indexForElement(event.target as HTMLElement);
116+
this._foundation.handleKeydown(
117+
event, this._elementAtIndex(index) === event.target, index);
118+
}
119+
120+
@HostListener('click', ['$event'])
121+
_handleClick(event: MouseEvent) {
122+
this._foundation.handleClick(this._indexForElement(event.target as HTMLElement), false);
123+
}
124+
125+
@HostListener('focusin', ['$event'])
126+
_handleFocusin(event: FocusEvent) {
127+
this._foundation.handleFocusIn(event, this._indexForElement(event.target as HTMLElement));
128+
}
129+
130+
@HostListener('focusout', ['$event'])
131+
_handleFocusout(event: FocusEvent) {
132+
this._foundation.handleFocusOut(event, this._indexForElement(event.target as HTMLElement));
133+
}
134+
135+
@ContentChildren(MatListItemBase, {descendants: true}) _items: QueryList<MatListItemBase>;
136+
137+
protected _adapter: MDCListAdapter = {
138+
getListItemCount: () => this._items.length,
139+
listItemAtIndexHasClass:
140+
(index, className) => this._elementAtIndex(index).classList.contains(className),
141+
addClassForElementIndex:
142+
(index, className) => this._elementAtIndex(index).classList.add(className),
143+
removeClassForElementIndex:
144+
(index, className) => this._elementAtIndex(index).classList.remove(className),
145+
getAttributeForElementIndex: (index, attr) => this._elementAtIndex(index).getAttribute(attr),
146+
setAttributeForElementIndex:
147+
(index, attr, value) => this._elementAtIndex(index).setAttribute(attr, value),
148+
getFocusedElementIndex: () => this._indexForElement(this._document?.activeElement),
149+
isFocusInsideList: () => this._element.nativeElement.contains(this._document?.activeElement),
150+
isRootFocused: () => this._element.nativeElement === this._document?.activeElement,
151+
focusItemAtIndex: index => this._elementAtIndex(index).focus(),
152+
153+
// MDC uses this method to disable focusable children of list items. However, we believe that
154+
// this is not an accessible pattern and should be avoided, therefore we intentionally do not
155+
// implement this method. In addition, implementing this would require violating Angular
156+
// Material's general principle of not having components modify DOM elements they do not own.
157+
// A user who feels they really need this feature can simply listen to the `(focus)` and
158+
// `(blur)` events on the list item and enable/disable focus on the children themselves as
159+
// appropriate.
160+
setTabIndexForListItemChildren: () => {},
161+
162+
// The following methods have a dummy implementation in the base class because they are only
163+
// applicable to certain types of lists. They should be implemented for the concrete classes
164+
// where they are applicable.
165+
hasCheckboxAtIndex: () => false,
166+
hasRadioAtIndex: () => false,
167+
setCheckedCheckboxOrRadioAtIndex: () => {},
168+
isCheckboxCheckedAtIndex: () => false,
169+
170+
// TODO(mmalerba): Determine if we need to implement these.
171+
getPrimaryTextAtIndex: () => '',
172+
notifyAction: () => {},
173+
};
174+
175+
protected _foundation: MDCListFoundation;
176+
177+
protected _document: Document;
178+
179+
private _itemsArr: MatListItemBase[] = [];
180+
181+
private _subscriptions = new Subscription();
182+
183+
constructor(protected _element: ElementRef<HTMLElement>, @Inject(DOCUMENT) document: any) {
184+
super();
185+
this._document = document;
186+
this._isNonInteractive = false;
187+
this._foundation = new MDCListFoundation(this._adapter);
188+
}
189+
190+
ngAfterViewInit() {
191+
this._initItems();
192+
this._foundation.init();
193+
this._foundation.layout();
194+
}
91195

92196
ngOnDestroy() {
197+
this._foundation.destroy();
93198
this._subscriptions.unsubscribe();
94-
this._rippleRenderer._removeTriggerEvents();
199+
}
200+
201+
private _initItems() {
202+
this._subscriptions.add(
203+
this._items.changes.pipe(startWith(null))
204+
.subscribe(() => this._itemsArr = this._items.toArray()));
205+
for (let i = 0; this._itemsArr.length; i++) {
206+
this._itemsArr[i]._initDefaultTabIndex(i === 0 ? 0 : -1);
207+
}
208+
}
209+
210+
private _itemAtIndex(index: number): MatListItemBase {
211+
return this._itemsArr[index];
212+
}
213+
214+
private _elementAtIndex(index: number): HTMLElement {
215+
return this._itemAtIndex(index)._elementRef.nativeElement;
216+
}
217+
218+
private _indexForElement(element: Element | null) {
219+
return element ?
220+
this._itemsArr.findIndex(i => i._elementRef.nativeElement.contains(element)) : -1;
95221
}
96222
}
223+

src/material-experimental/mdc-list/list.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ export class MatListSubheaderCssMatStyler {}
6666
{provide: MatListBase, useExisting: MatList},
6767
]
6868
})
69-
export class MatList extends MatListBase {
70-
_isNonInteractive = true;
71-
}
69+
export class MatList extends MatListBase {}
7270

7371
@Component({
7472
selector: 'mat-list-item, a[mat-list-item], button[mat-list-item]',
@@ -79,6 +77,9 @@ export class MatList extends MatListBase {
7977
templateUrl: 'list-item.html',
8078
encapsulation: ViewEncapsulation.None,
8179
changeDetection: ChangeDetectionStrategy.OnPush,
80+
providers: [
81+
{provide: MatListItemBase, useExisting: MatListItem},
82+
]
8283
})
8384
export class MatListItem extends MatListItemBase {
8485
@ContentChildren(MatLine, {read: ElementRef, descendants: true}) lines:

src/material-experimental/mdc-list/nav-list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

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

1313
@Component({
1414
selector: 'mat-nav-list',
@@ -33,4 +33,4 @@ import {MatListBase} from './list-base';
3333
{provide: MatList, useExisting: MatNavList},
3434
]
3535
})
36-
export class MatNavList extends MatListBase {}
36+
export class MatNavList extends MatInteractiveListBase {}

src/material-experimental/mdc-list/selection-list.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export class MatSelectionList extends MatListBase implements ControlValueAccesso
9595
templateUrl: 'list-option.html',
9696
encapsulation: ViewEncapsulation.None,
9797
changeDetection: ChangeDetectionStrategy.OnPush,
98+
providers: [
99+
{provide: MatListItemBase, useExisting: MatListOption},
100+
]
98101
})
99102
export class MatListOption extends MatListItemBase {
100103
static ngAcceptInputType_disabled: BooleanInput;

0 commit comments

Comments
 (0)