481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* @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.dev/license
|
||
|
|
*/
|
||
|
|
|
||
|
|
import {FocusKeyManager} from '@angular/cdk/a11y';
|
||
|
|
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
|
||
|
|
import {SelectionModel} from '@angular/cdk/collections';
|
||
|
|
import {A, ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
|
||
|
|
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
|
||
|
|
import {
|
||
|
|
AfterViewInit,
|
||
|
|
ChangeDetectionStrategy,
|
||
|
|
ChangeDetectorRef,
|
||
|
|
Component,
|
||
|
|
ContentChildren,
|
||
|
|
ElementRef,
|
||
|
|
EventEmitter,
|
||
|
|
Input,
|
||
|
|
NgZone,
|
||
|
|
OnChanges,
|
||
|
|
OnDestroy,
|
||
|
|
Output,
|
||
|
|
QueryList,
|
||
|
|
SimpleChanges,
|
||
|
|
ViewEncapsulation,
|
||
|
|
forwardRef,
|
||
|
|
inject,
|
||
|
|
} from '@angular/core';
|
||
|
|
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
|
||
|
|
import {ThemePalette} from '@angular/material/core';
|
||
|
|
import {Subject} from 'rxjs';
|
||
|
|
import {takeUntil} from 'rxjs/operators';
|
||
|
|
import {MatListBase} from './list-base';
|
||
|
|
import {MatListOption, SELECTION_LIST, SelectionList} from './list-option';
|
||
|
|
|
||
|
|
export const MAT_SELECTION_LIST_VALUE_ACCESSOR: any = {
|
||
|
|
provide: NG_VALUE_ACCESSOR,
|
||
|
|
useExisting: forwardRef(() => MatSelectionList),
|
||
|
|
multi: true,
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Change event that is being fired whenever the selected state of an option changes. */
|
||
|
|
export class MatSelectionListChange {
|
||
|
|
constructor(
|
||
|
|
/** Reference to the selection list that emitted the event. */
|
||
|
|
public source: MatSelectionList,
|
||
|
|
/** Reference to the options that have been changed. */
|
||
|
|
public options: MatListOption[],
|
||
|
|
) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Component({
|
||
|
|
selector: 'mat-selection-list',
|
||
|
|
exportAs: 'matSelectionList',
|
||
|
|
host: {
|
||
|
|
'class': 'mat-mdc-selection-list mat-mdc-list-base mdc-list',
|
||
|
|
'role': 'listbox',
|
||
|
|
'[attr.aria-multiselectable]': 'multiple',
|
||
|
|
'(keydown)': '_handleKeydown($event)',
|
||
|
|
},
|
||
|
|
template: '<ng-content></ng-content>',
|
||
|
|
styleUrl: 'list.css',
|
||
|
|
encapsulation: ViewEncapsulation.None,
|
||
|
|
providers: [
|
||
|
|
MAT_SELECTION_LIST_VALUE_ACCESSOR,
|
||
|
|
{provide: MatListBase, useExisting: MatSelectionList},
|
||
|
|
{provide: SELECTION_LIST, useExisting: MatSelectionList},
|
||
|
|
],
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
})
|
||
|
|
export class MatSelectionList
|
||
|
|
extends MatListBase
|
||
|
|
implements SelectionList, ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy
|
||
|
|
{
|
||
|
|
_element = inject<ElementRef<HTMLElement>>(ElementRef);
|
||
|
|
private _ngZone = inject(NgZone);
|
||
|
|
|
||
|
|
private _initialized = false;
|
||
|
|
private _keyManager: FocusKeyManager<MatListOption>;
|
||
|
|
|
||
|
|
/** Emits when the list has been destroyed. */
|
||
|
|
private _destroyed = new Subject<void>();
|
||
|
|
|
||
|
|
/** 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<MatListOption>;
|
||
|
|
|
||
|
|
/** Emits a change event whenever the selected state of an option changes. */
|
||
|
|
@Output() readonly selectionChange: EventEmitter<MatSelectionListChange> =
|
||
|
|
new EventEmitter<MatSelectionListChange>();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Theme color of the selection list. This sets the checkbox color for all
|
||
|
|
* list options. This API is supported in M2 themes only, it has no effect in
|
||
|
|
* M3 themes.
|
||
|
|
*
|
||
|
|
* For information on applying color variants in M3, see
|
||
|
|
* https://material.angular.io/guide/theming#using-component-color-variants.
|
||
|
|
*/
|
||
|
|
@Input() color: ThemePalette = 'accent';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Function used for comparing an option against the selected value when determining which
|
||
|
|
* options should appear as selected. The first argument is the value of an options. The second
|
||
|
|
* one is a value from the selected value. A boolean must be returned.
|
||
|
|
*/
|
||
|
|
@Input() compareWith: (o1: any, o2: any) => boolean = (a1, a2) => a1 === a2;
|
||
|
|
|
||
|
|
/** Whether selection is limited to one or multiple items (default multiple). */
|
||
|
|
@Input()
|
||
|
|
get multiple(): boolean {
|
||
|
|
return this._multiple;
|
||
|
|
}
|
||
|
|
set multiple(value: BooleanInput) {
|
||
|
|
const newValue = coerceBooleanProperty(value);
|
||
|
|
|
||
|
|
if (newValue !== this._multiple) {
|
||
|
|
if ((typeof ngDevMode === 'undefined' || ngDevMode) && this._initialized) {
|
||
|
|
throw new Error(
|
||
|
|
'Cannot change `multiple` mode of mat-selection-list after initialization.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
this._multiple = newValue;
|
||
|
|
this.selectedOptions = new SelectionModel(this._multiple, this.selectedOptions.selected);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
private _multiple = true;
|
||
|
|
|
||
|
|
/** Whether radio indicator for all list items is hidden. */
|
||
|
|
@Input()
|
||
|
|
get hideSingleSelectionIndicator(): boolean {
|
||
|
|
return this._hideSingleSelectionIndicator;
|
||
|
|
}
|
||
|
|
set hideSingleSelectionIndicator(value: BooleanInput) {
|
||
|
|
this._hideSingleSelectionIndicator = coerceBooleanProperty(value);
|
||
|
|
}
|
||
|
|
private _hideSingleSelectionIndicator: boolean =
|
||
|
|
this._defaultOptions?.hideSingleSelectionIndicator ?? false;
|
||
|
|
|
||
|
|
/** The currently selected options. */
|
||
|
|
selectedOptions = new SelectionModel<MatListOption>(this._multiple);
|
||
|
|
|
||
|
|
/** Keeps track of the currently-selected value. */
|
||
|
|
_value: string[] | null;
|
||
|
|
|
||
|
|
/** View to model callback that should be called if the list or its options lost focus. */
|
||
|
|
_onTouched: () => void = () => {};
|
||
|
|
|
||
|
|
private readonly _changeDetectorRef = inject(ChangeDetectorRef);
|
||
|
|
|
||
|
|
constructor(...args: unknown[]);
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this._isNonInteractive = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (this._value) {
|
||
|
|
this._setOptionsFromValues(this._value);
|
||
|
|
}
|
||
|
|
|
||
|
|
this._watchForSelectionChange();
|
||
|
|
}
|
||
|
|
|
||
|
|
ngOnChanges(changes: SimpleChanges) {
|
||
|
|
const disabledChanges = changes['disabled'];
|
||
|
|
const disableRippleChanges = changes['disableRipple'];
|
||
|
|
const hideSingleSelectionIndicatorChanges = changes['hideSingleSelectionIndicator'];
|
||
|
|
|
||
|
|
if (
|
||
|
|
(disableRippleChanges && !disableRippleChanges.firstChange) ||
|
||
|
|
(disabledChanges && !disabledChanges.firstChange) ||
|
||
|
|
(hideSingleSelectionIndicatorChanges && !hideSingleSelectionIndicatorChanges.firstChange)
|
||
|
|
) {
|
||
|
|
this._markOptionsForCheck();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ngOnDestroy() {
|
||
|
|
this._keyManager?.destroy();
|
||
|
|
this._element.nativeElement.removeEventListener('focusin', this._handleFocusin);
|
||
|
|
this._element.nativeElement.removeEventListener('focusout', this._handleFocusout);
|
||
|
|
this._destroyed.next();
|
||
|
|
this._destroyed.complete();
|
||
|
|
this._isDestroyed = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Focuses the selection list. */
|
||
|
|
focus(options?: FocusOptions) {
|
||
|
|
this._element.nativeElement.focus(options);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Selects all of the options. Returns the options that changed as a result. */
|
||
|
|
selectAll(): MatListOption[] {
|
||
|
|
return this._setAllOptionsSelected(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Deselects all of the options. Returns the options that changed as a result. */
|
||
|
|
deselectAll(): MatListOption[] {
|
||
|
|
return this._setAllOptionsSelected(false);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Reports a value change to the ControlValueAccessor */
|
||
|
|
_reportValueChange() {
|
||
|
|
// Stop reporting value changes after the list has been destroyed. This avoids
|
||
|
|
// cases where the list might wrongly reset its value once it is removed, but
|
||
|
|
// the form control is still live.
|
||
|
|
if (this.options && !this._isDestroyed) {
|
||
|
|
const value = this._getSelectedOptionValues();
|
||
|
|
this._onChange(value);
|
||
|
|
this._value = value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Emits a change event if the selected state of an option changed. */
|
||
|
|
_emitChangeEvent(options: MatListOption[]) {
|
||
|
|
this.selectionChange.emit(new MatSelectionListChange(this, options));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Implemented as part of ControlValueAccessor. */
|
||
|
|
writeValue(values: string[]): void {
|
||
|
|
this._value = values;
|
||
|
|
|
||
|
|
if (this.options) {
|
||
|
|
this._setOptionsFromValues(values || []);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Implemented as a part of ControlValueAccessor. */
|
||
|
|
setDisabledState(isDisabled: boolean): void {
|
||
|
|
this.disabled = isDisabled;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the *entire* selection list is disabled. When true, each list item is also disabled
|
||
|
|
* and each list item is removed from the tab order (has tabindex="-1").
|
||
|
|
*/
|
||
|
|
@Input()
|
||
|
|
override get disabled(): boolean {
|
||
|
|
return this._selectionListDisabled;
|
||
|
|
}
|
||
|
|
override set disabled(value: BooleanInput) {
|
||
|
|
// Update the disabled state of this list. Write to `this._selectionListDisabled` instead of
|
||
|
|
// `super.disabled`. That is to avoid closure compiler compatibility issues with assigning to
|
||
|
|
// a super property.
|
||
|
|
this._selectionListDisabled = coerceBooleanProperty(value);
|
||
|
|
if (this._selectionListDisabled) {
|
||
|
|
this._keyManager?.setActiveItem(-1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
private _selectionListDisabled = false;
|
||
|
|
|
||
|
|
/** Implemented as part of ControlValueAccessor. */
|
||
|
|
registerOnChange(fn: (value: any) => void): void {
|
||
|
|
this._onChange = fn;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Implemented as part of ControlValueAccessor. */
|
||
|
|
registerOnTouched(fn: () => void): void {
|
||
|
|
this._onTouched = fn;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Watches for changes in the selected state of the options and updates the list accordingly. */
|
||
|
|
private _watchForSelectionChange() {
|
||
|
|
this.selectedOptions.changed.pipe(takeUntil(this._destroyed)).subscribe(event => {
|
||
|
|
// Sync external changes to the model back to the options.
|
||
|
|
for (let item of event.added) {
|
||
|
|
item.selected = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let item of event.removed) {
|
||
|
|
item.selected = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this._containsFocus()) {
|
||
|
|
this._resetActiveOption();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Sets the selected options based on the specified values. */
|
||
|
|
private _setOptionsFromValues(values: string[]) {
|
||
|
|
this.options.forEach(option => option._setSelected(false));
|
||
|
|
|
||
|
|
values.forEach(value => {
|
||
|
|
const correspondingOption = this.options.find(option => {
|
||
|
|
// Skip options that are already in the model. This allows us to handle cases
|
||
|
|
// where the same primitive value is selected multiple times.
|
||
|
|
return option.selected ? false : this.compareWith(option.value, value);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (correspondingOption) {
|
||
|
|
correspondingOption._setSelected(true);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Returns the values of the selected options. */
|
||
|
|
private _getSelectedOptionValues(): string[] {
|
||
|
|
return this.options.filter(option => option.selected).map(option => option.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Marks all the options to be checked in the next change detection run. */
|
||
|
|
private _markOptionsForCheck() {
|
||
|
|
if (this.options) {
|
||
|
|
this.options.forEach(option => option._markForCheck());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sets the selected state on all of the options
|
||
|
|
* and emits an event if anything changed.
|
||
|
|
*/
|
||
|
|
private _setAllOptionsSelected(isSelected: boolean, skipDisabled?: boolean): MatListOption[] {
|
||
|
|
// Keep track of whether anything changed, because we only want to
|
||
|
|
// emit the changed event when something actually changed.
|
||
|
|
const changedOptions: MatListOption[] = [];
|
||
|
|
|
||
|
|
this.options.forEach(option => {
|
||
|
|
if ((!skipDisabled || !option.disabled) && option._setSelected(isSelected)) {
|
||
|
|
changedOptions.push(option);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (changedOptions.length) {
|
||
|
|
this._reportValueChange();
|
||
|
|
}
|
||
|
|
|
||
|
|
return changedOptions;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Note: This getter exists for backwards compatibility. The `_items` query list
|
||
|
|
// cannot be named `options` as it will be picked up by the interactive list base.
|
||
|
|
/** The option components contained within this selection-list. */
|
||
|
|
get options(): QueryList<MatListOption> {
|
||
|
|
return this._items;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Handles focusin events within the list. */
|
||
|
|
private _handleFocusin = (event: FocusEvent) => {
|
||
|
|
if (this.disabled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
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.
|
||
|
|
*
|
||
|
|
* `skipPredicate` determines if key manager should avoid putting a given list item in the tab
|
||
|
|
* index. Allow disabled list items to receive focus to align with WAI ARIA recommendation.
|
||
|
|
* Normally WAI ARIA's instructions are to exclude disabled items from the tab order, but it
|
||
|
|
* makes a few exceptions for compound widgets.
|
||
|
|
*
|
||
|
|
* From [Developing a Keyboard Interface](
|
||
|
|
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
|
||
|
|
* "For the following composite widget elements, keep them focusable when disabled: Options in a
|
||
|
|
* Listbox..."
|
||
|
|
*/
|
||
|
|
private _setupRovingTabindex() {
|
||
|
|
this._keyManager = new FocusKeyManager(this._items)
|
||
|
|
.withHomeAndEnd()
|
||
|
|
.withTypeAhead()
|
||
|
|
.withWrap()
|
||
|
|
.skipPredicate(() => this.disabled);
|
||
|
|
|
||
|
|
// Set the initial focus.
|
||
|
|
this._resetActiveOption();
|
||
|
|
|
||
|
|
// Move the tabindex to the currently-focused list item.
|
||
|
|
this._keyManager.change.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) === -1) {
|
||
|
|
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. When the list is disabled, remove all options from to the tab order.
|
||
|
|
* Otherwise, focus the first selected option.
|
||
|
|
*/
|
||
|
|
private _resetActiveOption() {
|
||
|
|
if (this.disabled) {
|
||
|
|
this._setActiveOption(-1);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|