From 3f3dd0d607d4b3f02da320babf10d03f4c3eb1ab Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 30 May 2022 13:27:51 +0200 Subject: [PATCH] refactor(material-experimental/mdc-list): remove usage of MDC adapter Reworks our list not to use MDC's adapters. --- .../mdc-list/BUILD.bazel | 2 - .../mdc-list/interactive-list-base.ts | 225 --------------- .../mdc-list/list-base.ts | 11 +- .../mdc-list/list-option.ts | 55 +++- .../mdc-list/selection-list.spec.ts | 15 +- .../mdc-list/selection-list.ts | 260 +++++++++--------- 6 files changed, 184 insertions(+), 384 deletions(-) delete mode 100644 src/material-experimental/mdc-list/interactive-list-base.ts diff --git a/src/material-experimental/mdc-list/BUILD.bazel b/src/material-experimental/mdc-list/BUILD.bazel index d79c5ca03765..ac88d03b8f19 100644 --- a/src/material-experimental/mdc-list/BUILD.bazel +++ b/src/material-experimental/mdc-list/BUILD.bazel @@ -33,7 +33,6 @@ ng_module( "//src/material/list", "@npm//@angular/core", "@npm//@angular/forms", - "@npm//@material/list", ], ) @@ -89,7 +88,6 @@ ng_test_library( "//src/material-experimental/mdc-core", "@npm//@angular/forms", "@npm//@angular/platform-browser", - "@npm//@material/list", ], ) diff --git a/src/material-experimental/mdc-list/interactive-list-base.ts b/src/material-experimental/mdc-list/interactive-list-base.ts deleted file mode 100644 index c97bdcea29d1..000000000000 --- a/src/material-experimental/mdc-list/interactive-list-base.ts +++ /dev/null @@ -1,225 +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 {DOCUMENT} from '@angular/common'; -import {AfterViewInit, Directive, ElementRef, Inject, OnDestroy, QueryList} from '@angular/core'; -import {MDCListAdapter, MDCListFoundation} from '@material/list'; -import {Subscription} from 'rxjs'; -import {startWith} from 'rxjs/operators'; -import {MatListBase, MatListItemBase} from './list-base'; - -@Directive({ - host: { - '(keydown)': '_handleKeydown($event)', - '(click)': '_handleClick($event)', - '(focusin)': '_handleFocusin($event)', - '(focusout)': '_handleFocusout($event)', - }, -}) -/** @docs-private */ -export abstract class MatInteractiveListBase - extends MatListBase - implements AfterViewInit, OnDestroy -{ - _handleKeydown(event: KeyboardEvent) { - const index = this._indexForElement(event.target as HTMLElement); - this._foundation.handleKeydown(event, this._elementAtIndex(index) === event.target, index); - } - - _handleClick(event: MouseEvent) { - // The `isCheckboxAlreadyUpdatedInAdapter` parameter can always be `false` as it only has an - // effect if the list is recognized as checkbox selection list. For such lists, we would - // always want to toggle the checkbox on list item click. MDC added this parameter so that - // they can avoid dispatching a fake `change` event when the checkbox is directly clicked - // for the list item. We don't need this as we do not have an underlying native checkbox - // that is reachable by users through interaction. - // https://github.com/material-components/material-components-web/blob/08ca4d0ec5f359bc3a20bd2a302fa6b733b5e135/packages/mdc-list/component.ts#L308-L310 - this._foundation.handleClick( - this._indexForElement(event.target as HTMLElement), - /* isCheckboxAlreadyUpdatedInAdapter */ false, - event, - ); - } - - _handleFocusin(event: FocusEvent) { - const itemIndex = this._indexForElement(event.target as HTMLElement); - const tabIndex = this._itemsArr[itemIndex]?._hostElement.tabIndex; - - // If the newly focused item is not the designated item that should have received focus - // first through keyboard interaction, the tabindex of the previously designated list item - // needs to be cleared, so that only one list item is reachable through tab key at any time. - // MDC sets a tabindex for the newly focused item, so we do not need to set a tabindex for it. - // Workaround for: https://github.com/material-components/material-components-web/issues/6363. - if (tabIndex === undefined || tabIndex === -1) { - this._clearTabindexForAllItems(); - } - - this._foundation.handleFocusIn(itemIndex); - } - - _handleFocusout(event: FocusEvent) { - this._foundation.handleFocusOut(this._indexForElement(event.target as HTMLElement)); - } - - /** Items in the interactive list. */ - abstract _items: QueryList; - _itemsArr: T[] = []; - _document: Document; - - protected _foundation: MDCListFoundation; - protected _adapter: MDCListAdapter; - - private _subscriptions = new Subscription(); - - protected constructor(public _element: ElementRef, @Inject(DOCUMENT) document: any) { - super(); - this._document = document; - this._isNonInteractive = false; - } - - protected _initWithAdapter(adapter: MDCListAdapter) { - this._adapter = adapter; - this._foundation = new MDCListFoundation(adapter); - } - - ngAfterViewInit() { - if ((typeof ngDevMode === 'undefined' || ngDevMode) && !this._foundation) { - throw Error('MDC list foundation not initialized for Angular Material list.'); - } - - this._foundation.init(); - this._watchListItems(); - - // Enable typeahead and focus wrapping for interactive lists. - this._foundation.setHasTypeahead(true); - this._foundation.setWrapFocus(true); - } - - ngOnDestroy() { - this._foundation.destroy(); - this._subscriptions.unsubscribe(); - } - - protected _watchListItems() { - this._subscriptions.add( - this._items.changes.pipe(startWith(null)).subscribe(() => { - this._itemsArr = this._items.toArray(); - // Whenever the items change, the foundation needs to be notified through the `layout` - // method. It caches items for the typeahead and detects the list type based on the items. - this._foundation.layout(); - - // The list items changed, so we reset the tabindex for all items and - // designate one list item that will be reachable through tab. - this._resetTabindexToFirstSelectedOrFocusedItem(); - }), - ); - } - - /** - * Clears the tabindex of all items so that no items are reachable through tab key. - * MDC intends to always have only one tabbable item that will receive focus first. - * This first item is selected by MDC automatically on blur or by manually invoking - * the `setTabindexToFirstSelectedOrFocusedItem` method. - */ - private _clearTabindexForAllItems() { - for (let items of this._itemsArr) { - items._hostElement.setAttribute('tabindex', '-1'); - } - } - - /** - * Resets tabindex for all options and sets tabindex for the first selected option or - * previously focused item so that an item can be reached when users tab into the list. - */ - protected _resetTabindexToFirstSelectedOrFocusedItem() { - this._clearTabindexForAllItems(); - // MDC does not expose the method for setting the tabindex to the first selected - // or previously focused item. We can still access the method as private class - // members are accessible in the transpiled JavaScript. Tracked upstream with: - // TODO: https://github.com/material-components/material-components-web/issues/6375 - (this._foundation as any).setTabindexToFirstSelectedOrFocusedItem(); - } - - _elementAtIndex(index: number): HTMLElement | undefined { - return this._itemsArr[index]?._hostElement; - } - - _indexForElement(element: Element | null): number { - return element ? this._itemsArr.findIndex(i => i._hostElement.contains(element)) : -1; - } -} - -// TODO: replace with class once material-components-web/pull/6256 is available. -/** Gets an instance of `MDcListAdapter` for the given interactive list. */ -export function getInteractiveListAdapter( - list: MatInteractiveListBase, -): MDCListAdapter { - return { - getListItemCount() { - return list._items.length; - }, - listItemAtIndexHasClass(index: number, className: string) { - const element = list._elementAtIndex(index); - return element ? element.classList.contains(className) : false; - }, - addClassForElementIndex(index: number, className: string) { - list._elementAtIndex(index)?.classList.add(className); - }, - removeClassForElementIndex(index: number, className: string) { - list._elementAtIndex(index)?.classList.remove(className); - }, - getAttributeForElementIndex(index: number, attr: string) { - const element = list._elementAtIndex(index); - return element ? element.getAttribute(attr) : null; - }, - setAttributeForElementIndex(index: number, attr: string, value: string) { - list._elementAtIndex(index)?.setAttribute(attr, value); - }, - getFocusedElementIndex() { - return list._indexForElement(list._document?.activeElement); - }, - isFocusInsideList() { - return list._element.nativeElement.contains(list._document?.activeElement); - }, - isRootFocused() { - return list._element.nativeElement === list._document?.activeElement; - }, - focusItemAtIndex(index: number) { - list._elementAtIndex(index)?.focus(); - }, - // Gets the text for a list item for the typeahead - getPrimaryTextAtIndex(index: number) { - return list._itemsArr[index]._getItemLabel(); - }, - - // 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() { - return false; - }, - hasRadioAtIndex(index: number) { - return false; - }, - setCheckedCheckboxOrRadioAtIndex(index: number, checked: boolean) {}, - isCheckboxCheckedAtIndex(index: number) { - return false; - }, - notifySelectionChange() {}, - notifyAction() {}, - }; -} diff --git a/src/material-experimental/mdc-list/list-base.ts b/src/material-experimental/mdc-list/list-base.ts index 13081d1d950c..ab5a136b44d0 100644 --- a/src/material-experimental/mdc-list/list-base.ts +++ b/src/material-experimental/mdc-list/list-base.ts @@ -125,7 +125,7 @@ export abstract class MatListItemBase implements AfterViewInit, OnDestroy, Rippl return this.disableRipple || !!this.rippleConfig.disabled; } - protected constructor( + constructor( public _elementRef: ElementRef, protected _ngZone: NgZone, private _listBase: MatListBase, @@ -166,15 +166,6 @@ export abstract class MatListItemBase implements AfterViewInit, OnDestroy, Rippl } } - /** Gets the label for the list item. This is used for the typeahead. */ - _getItemLabel(): string { - 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. */ _hasIconOrAvatar() { return !!(this._avatars.length || this._icons.length); diff --git a/src/material-experimental/mdc-list/list-option.ts b/src/material-experimental/mdc-list/list-option.ts index 42a15921f07e..531a9d4deb92 100644 --- a/src/material-experimental/mdc-list/list-option.ts +++ b/src/material-experimental/mdc-list/list-option.ts @@ -8,8 +8,8 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; -import {Platform} from '@angular/cdk/platform'; import { + ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -33,10 +33,10 @@ import { RippleGlobalOptions, ThemePalette, } from '@angular/material-experimental/mdc-core'; -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'; +import {Platform} from '@angular/cdk/platform'; /** * Injection token that can be used to reference instances of an `SelectionList`. It serves @@ -56,8 +56,9 @@ export interface SelectionList extends MatListBase { selectedOptions: SelectionModel; compareWith: (o1: any, o2: any) => boolean; _value: string[] | null; - _reportValueChange: () => void; - _onTouched: () => void; + _reportValueChange(): void; + _emitChangeEvent(options: MatListOption[]): void; + _onTouched(): void; } @Component({ @@ -83,7 +84,9 @@ export interface SelectionList extends MatListBase { '[class.mat-accent]': 'color !== "primary" && color !== "warn"', '[class.mat-warn]': 'color === "warn"', '[class._mat-animation-noopable]': '_noopAnimations', + '[attr.aria-selected]': 'selected', '(blur)': '_handleBlur()', + '(click)': '_toggleOnInteraction()', }, templateUrl: 'list-option.html', encapsulation: ViewEncapsulation.None, @@ -97,7 +100,6 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit @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. @@ -159,21 +161,17 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit private _inputsInitialized = false; constructor( - element: ElementRef, + elementRef: ElementRef, ngZone: NgZone, + @Inject(SELECTION_LIST) private _selectionList: SelectionList, platform: Platform, - @Inject(SELECTION_LIST) public _selectionList: SelectionList, private _changeDetectorRef: ChangeDetectorRef, - @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalRippleOptions?: RippleGlobalOptions, + @Optional() + @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) + globalRippleOptions?: RippleGlobalOptions, @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, ) { - super(element, ngZone, _selectionList, platform, globalRippleOptions, animationMode); - - // By default, we mark all options as unselected. The MDC list foundation will - // automatically update the attribute based on selection. Note that we need to - // initially set this because MDC does not set the default attributes for list - // items but expects items to be set up properly in the static markup. - element.nativeElement.setAttribute('aria-selected', 'false'); + super(elementRef, ngZone, _selectionList, platform, globalRippleOptions, animationMode); } ngOnInit() { @@ -221,6 +219,15 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit this._hostElement.focus(); } + /** Gets the text label of the list option. Used for the typeahead functionality in the list. */ + getLabel() { + 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?.textContent || ''; + } + /** Whether a checkbox is shown at the given position. */ _hasCheckboxAt(position: MatListOptionCheckboxPosition): boolean { return this._selectionList.multiple && this._getCheckboxPosition() === position; @@ -280,4 +287,22 @@ export class MatListOption extends MatListItemBase implements ListOption, OnInit _markForCheck() { this._changeDetectorRef.markForCheck(); } + + /** Toggles the option's value based on a user interacion. */ + _toggleOnInteraction() { + if (!this.disabled) { + if (this._selectionList.multiple) { + this.selected = !this.selected; + this._selectionList._emitChangeEvent([this]); + } else if (!this.selected) { + this.selected = true; + this._selectionList._emitChangeEvent([this]); + } + } + } + + /** Sets the tabindex of the list option. */ + _setTabindex(value: number) { + this._hostElement.setAttribute('tabindex', value + ''); + } } diff --git a/src/material-experimental/mdc-list/selection-list.spec.ts b/src/material-experimental/mdc-list/selection-list.spec.ts index d83e0c20795d..6a07a4443769 100644 --- a/src/material-experimental/mdc-list/selection-list.spec.ts +++ b/src/material-experimental/mdc-list/selection-list.spec.ts @@ -24,7 +24,6 @@ import { import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; import {ThemePalette} from '@angular/material-experimental/mdc-core'; import {By} from '@angular/platform-browser'; -import {numbers} from '@material/list'; import { MatListModule, MatListOption, @@ -34,6 +33,8 @@ import { } from './index'; describe('MDC-based MatSelectionList without forms', () => { + const typeaheadInterval = 200; + describe('with list option', () => { let fixture: ComponentFixture; let listOptions: DebugElement[]; @@ -445,13 +446,13 @@ describe('MDC-based MatSelectionList without forms', () => { dispatchEvent(firstOption, createKeyboardEvent('keydown', 83, 's')); fixture.detectChanges(); - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS); + tick(typeaheadInterval); expect(getFocusIndex()).toBe(1); dispatchEvent(firstOption, createKeyboardEvent('keydown', 68, 'd')); fixture.detectChanges(); - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS); + tick(typeaheadInterval); expect(getFocusIndex()).toBe(4); })); @@ -462,7 +463,7 @@ describe('MDC-based MatSelectionList without forms', () => { dispatchKeyboardEvent(listOptions[0].nativeElement, 'keydown', D, 'd'); fixture.detectChanges(); - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS); + tick(typeaheadInterval); expect(getFocusIndex()).toBe(4); })); @@ -475,7 +476,7 @@ describe('MDC-based MatSelectionList without forms', () => { dispatchKeyboardEvent(listOptions[0].nativeElement, 'keydown', A, 'a'); fixture.detectChanges(); - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS); + tick(typeaheadInterval); expect(getFocusIndex()).toBe(3); })); @@ -493,13 +494,13 @@ describe('MDC-based MatSelectionList without forms', () => { dispatchKeyboardEvent(testListItem, 'keydown', D, 'd'); fixture.detectChanges(); - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS / 2); // Tick only half the typeahead timeout. + tick(typeaheadInterval / 2); // Tick only half the typeahead timeout. dispatchKeyboardEvent(testListItem, 'keydown', SPACE); fixture.detectChanges(); // Tick the buffer timeout again as a new key has been pressed that resets // the buffer timeout. - tick(numbers.TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS); + tick(typeaheadInterval); expect(getFocusIndex()).toBe(4); expect(model.isEmpty()).toBe(true); diff --git a/src/material-experimental/mdc-list/selection-list.ts b/src/material-experimental/mdc-list/selection-list.ts index a1019934831c..78f5aa167963 100644 --- a/src/material-experimental/mdc-list/selection-list.ts +++ b/src/material-experimental/mdc-list/selection-list.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ +import {FocusKeyManager} from '@angular/cdk/a11y'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; -import {DOCUMENT} from '@angular/common'; +import {A, ENTER, hasModifierKey, SPACE} from '@angular/cdk/keycodes'; +import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import { AfterViewInit, ChangeDetectionStrategy, @@ -17,8 +19,8 @@ import { ElementRef, EventEmitter, forwardRef, - Inject, Input, + NgZone, OnChanges, OnDestroy, Output, @@ -28,10 +30,8 @@ import { } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {ThemePalette} from '@angular/material-experimental/mdc-core'; -import {MDCListAdapter, numbers as mdcListNumbers} from '@material/list'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; -import {getInteractiveListAdapter, MatInteractiveListBase} from './interactive-list-base'; import {MatListBase} from './list-base'; import {MatListOption, SELECTION_LIST, SelectionList} from './list-option'; @@ -64,6 +64,7 @@ export class MatSelectionListChange { 'class': 'mat-mdc-selection-list mat-mdc-list-base mdc-list', 'role': 'listbox', '[attr.aria-multiselectable]': 'multiple', + '(keydown)': '_handleKeydown($event)', }, template: '', styleUrls: ['list.css'], @@ -76,11 +77,20 @@ export class MatSelectionListChange { changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatSelectionList - extends MatInteractiveListBase + extends MatListBase implements SelectionList, ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy { - private _multiple = true; private _initialized = false; + private _keyManager: FocusKeyManager; + + /** Emits when the list has been destroyed. */ + private _destroyed = new Subject(); + + /** Whether the list has been destroyed. */ + private _isDestroyed: boolean; + + /** View to model callback that should be called whenever the selected options change. */ + private _onChange: (value: any) => void = (_: any) => {}; @ContentChildren(MatListOption, {descendants: true}) _items: QueryList; @@ -117,57 +127,40 @@ export class MatSelectionList this.selectedOptions = new SelectionModel(this._multiple, this.selectedOptions.selected); } } + private _multiple = true; /** The currently selected options. */ selectedOptions = new SelectionModel(this._multiple); - /** View to model callback that should be called whenever the selected options change. */ - private _onChange: (value: any) => void = (_: any) => {}; - /** Keeps track of the currently-selected value. */ _value: string[] | null; - /** Emits when the list has been destroyed. */ - private _destroyed = new Subject(); - /** View to model callback that should be called if the list or its options lost focus. */ _onTouched: () => void = () => {}; - /** Whether the list has been destroyed. */ - private _isDestroyed: boolean; - - constructor(element: ElementRef, @Inject(DOCUMENT) document: any) { - super(element, document); - super._initWithAdapter(getSelectionListAdapter(this)); + constructor(public _element: ElementRef, private _ngZone: NgZone) { + super(); + this._isNonInteractive = false; } - override ngAfterViewInit() { + ngAfterViewInit() { // Mark the selection list as initialized so that the `multiple` // binding can no longer be changed. this._initialized = true; + this._setupRovingTabindex(); + + // These events are bound outside the zone, because they don't change + // any change-detected properties and they can trigger timeouts. + this._ngZone.runOutsideAngular(() => { + this._element.nativeElement.addEventListener('focusin', this._handleFocusin); + this._element.nativeElement.addEventListener('focusout', this._handleFocusout); + }); - // Update the options if a control value has been set initially. Note that this should happen - // before watching for selection changes as otherwise we would sync options with MDC multiple - // times as part of view initialization (also the foundation would not be initialized yet). if (this._value) { this._setOptionsFromValues(this._value); } - // Start monitoring the selected options so that the list foundation can be - // updated accordingly. this._watchForSelectionChange(); - - // Initialize the list foundation, including the initial `layout()` invocation. - super.ngAfterViewInit(); - - // List options can be pre-selected using the `selected` input. We need to sync the selected - // options after view initialization with the foundation so that focus can be managed - // accordingly. Note that this needs to happen after the initial `layout()` call because the - // list wouldn't know about multi-selection and throw. - if (this._items.length !== 0) { - this._syncSelectedOptionsWithFoundation(); - this._resetTabindexForItemsIfBlurred(); - } } ngOnChanges(changes: SimpleChanges) { @@ -182,8 +175,9 @@ export class MatSelectionList } } - override ngOnDestroy() { - super.ngOnDestroy(); + ngOnDestroy() { + this._element.nativeElement.removeEventListener('focusin', this._handleFocusin); + this._element.nativeElement.removeEventListener('focusout', this._handleFocusout); this._destroyed.next(); this._destroyed.complete(); this._isDestroyed = true; @@ -245,61 +239,24 @@ export class MatSelectionList this._onTouched = fn; } - /** - * Resets tabindex for all options and sets tabindex for the first selected option so that - * it will become active when users tab into the selection-list. This will be a noop if the - * list is currently focused as otherwise multiple options might become reachable through tab. - * e.g. A user currently already focused an option. We set tabindex to a new option but the - * focus on the current option does persist. Pressing `TAB` then might go to the other option - * that received a tabindex. We can skip the reset here as the MDC foundation resets the - * tabindex to the first selected option automatically once the current item is blurred. - */ - private _resetTabindexForItemsIfBlurred() { - // If focus is inside the list already, then we do not change the tab index of the list. - // Changing it while an item is focused could cause multiple items to be reachable through - // the tab key. The MDC list foundation will update the tabindex on blur to the appropriate - // selected or focused item. - if (!this._adapter.isFocusInsideList()) { - this._resetTabindexToFirstSelectedOrFocusedItem(); - } - } - + /** Watches for changes in the selected state of the options and updates the list accordingly. */ private _watchForSelectionChange() { - // Sync external changes to the model back to the options. this.selectedOptions.changed.pipe(takeUntil(this._destroyed)).subscribe(event => { - if (event.added) { - for (let item of event.added) { - item.selected = true; - } + // Sync external changes to the model back to the options. + for (let item of event.added) { + item.selected = true; } - if (event.removed) { - for (let item of event.removed) { - item.selected = false; - } + for (let item of event.removed) { + item.selected = false; } - // Sync the newly selected options with the foundation. Also reset tabindex for all - // items if the list is currently not focused. We do this so that always the first - // selected list item is focused when users tab into the selection list. - this._syncSelectedOptionsWithFoundation(); - this._resetTabindexForItemsIfBlurred(); + if (!this._containsFocus()) { + this._resetActiveOption(); + } }); } - private _syncSelectedOptionsWithFoundation() { - if (this._multiple) { - this._foundation.setSelectedIndex( - this.selectedOptions.selected.map(o => this._itemsArr.indexOf(o)), - ); - } else { - const selected = this.selectedOptions.selected[0]; - const index = - selected === undefined ? mdcListNumbers.UNSET_INDEX : this._itemsArr.indexOf(selected); - this._foundation.setSelectedIndex(index); - } - } - /** Sets the selected options based on the specified values. */ private _setOptionsFromValues(values: string[]) { this.options.forEach(option => option._setSelected(false)); @@ -357,49 +314,102 @@ export class MatSelectionList get options(): QueryList { return this._items; } -} -// TODO: replace with class using inheritance once material-components-web/pull/6256 is available. -/** Gets a `MDCListAdapter` instance for the given selection list. */ -function getSelectionListAdapter(list: MatSelectionList): MDCListAdapter { - const baseAdapter = getInteractiveListAdapter(list); - return { - ...baseAdapter, - hasRadioAtIndex(): boolean { - // If multi selection is not used, we treat the list as a radio list so that - // the MDC foundation does not keep track of multiple selected list options. - // Note that we cannot use MDC's non-radio single selection mode as that one - // will keep track of the selection state internally and we cannot update a - // control model, or notify/update list-options on selection change. The radio - // mode is similar to what we want but with support for change notification - // (i.e. `setCheckedCheckboxOrRadioAtIndex`) while maintaining single selection. - return !list.multiple; - }, - hasCheckboxAtIndex() { - // If multi selection is used, we treat the list as a checkbox list so that - // the MDC foundation can keep track of multiple selected list options. - return list.multiple; - }, - isCheckboxCheckedAtIndex(index: number) { - return list._itemsArr[index].selected; - }, - setCheckedCheckboxOrRadioAtIndex(index: number, checked: boolean) { - list._itemsArr[index].selected = checked; - }, - setAttributeForElementIndex(index: number, attribute: string, value: string): void { - // MDC list by default sets `aria-checked` for multi selection lists. We do not want to - // use this as that signifies a bad accessibility experience. Instead, we change the - // attribute update to `aria-selected` as that works best with list-options. See: - // https://github.com/material-components/material-components-web/issues/6367. - // TODO: Remove this once material-components-web#6367 is improved/fixed. - if (attribute === 'aria-checked') { - attribute = 'aria-selected'; + /** Handles keydown events within the list. */ + _handleKeydown(event: KeyboardEvent) { + const activeItem = this._keyManager.activeItem; + + if ( + (event.keyCode === ENTER || event.keyCode === SPACE) && + !this._keyManager.isTyping() && + activeItem && + !activeItem.disabled + ) { + event.preventDefault(); + activeItem._toggleOnInteraction(); + } else if ( + event.keyCode === A && + this.multiple && + !this._keyManager.isTyping() && + hasModifierKey(event, 'ctrlKey') + ) { + const shouldSelect = this.options.some(option => !option.disabled && !option.selected); + event.preventDefault(); + this._emitChangeEvent(this._setAllOptionsSelected(shouldSelect, true)); + } else { + this._keyManager.onKeydown(event); + } + } + + /** Handles focusout events within the list. */ + private _handleFocusout = () => { + // Focus takes a while to update so we have to wrap our call in a timeout. + setTimeout(() => { + if (!this._containsFocus()) { + this._resetActiveOption(); } + }); + }; - baseAdapter.setAttributeForElementIndex(index, attribute, value); - }, - notifySelectionChange(changedIndices: number[]): void { - list._emitChangeEvent(changedIndices.map(index => list._itemsArr[index])); - }, + /** Handles focusin events within the list. */ + private _handleFocusin = (event: FocusEvent) => { + const activeIndex = this._items + .toArray() + .findIndex(item => item._elementRef.nativeElement.contains(event.target as HTMLElement)); + + if (activeIndex > -1) { + this._setActiveOption(activeIndex); + } else { + this._resetActiveOption(); + } }; + + /** Sets up the logic for maintaining the roving tabindex. */ + private _setupRovingTabindex() { + this._keyManager = new FocusKeyManager(this._items) + .withHomeAndEnd() + .withTypeAhead() + .withWrap() + // Allow navigation to disabled items. + .skipPredicate(() => false); + + // Set the initial focus. + this._resetActiveOption(); + + // Move the tabindex to the currently-focused list item. + this._keyManager.change + .pipe(takeUntil(this._destroyed)) + .subscribe(activeItemIndex => this._setActiveOption(activeItemIndex)); + + // If the active item is removed from the list, reset back to the first one. + this._items.changes.pipe(takeUntil(this._destroyed)).subscribe(() => { + const activeItem = this._keyManager.activeItem; + + if (!activeItem || !this._items.toArray().indexOf(activeItem)) { + this._resetActiveOption(); + } + }); + } + + /** + * Sets an option as active. + * @param index Index of the active option. If set to -1, no option will be active. + */ + private _setActiveOption(index: number) { + this._items.forEach((item, itemIndex) => item._setTabindex(itemIndex === index ? 0 : -1)); + this._keyManager.updateActiveItem(index); + } + + /** Resets the active option to the first selected option. */ + private _resetActiveOption() { + const activeItem = + this._items.find(item => item.selected && !item.disabled) || this._items.first; + this._setActiveOption(activeItem ? this._items.toArray().indexOf(activeItem) : -1); + } + + /** Returns whether the focus is currently within the list. */ + private _containsFocus() { + const activeElement = _getFocusedElementPierceShadowDom(); + return activeElement && this._element.nativeElement.contains(activeElement); + } }