347 lines
11 KiB
TypeScript
347 lines
11 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 {_IdGenerator, FocusableOption, FocusOrigin} from '@angular/cdk/a11y';
|
||
|
|
import {ENTER, hasModifierKey, SPACE} from '@angular/cdk/keycodes';
|
||
|
|
import {
|
||
|
|
Component,
|
||
|
|
ViewEncapsulation,
|
||
|
|
ChangeDetectionStrategy,
|
||
|
|
ElementRef,
|
||
|
|
ChangeDetectorRef,
|
||
|
|
AfterViewChecked,
|
||
|
|
OnDestroy,
|
||
|
|
Input,
|
||
|
|
Output,
|
||
|
|
EventEmitter,
|
||
|
|
QueryList,
|
||
|
|
ViewChild,
|
||
|
|
booleanAttribute,
|
||
|
|
inject,
|
||
|
|
isSignal,
|
||
|
|
Signal,
|
||
|
|
} from '@angular/core';
|
||
|
|
import {Subject} from 'rxjs';
|
||
|
|
import {MAT_OPTGROUP, MatOptgroup} from './optgroup';
|
||
|
|
import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent';
|
||
|
|
import {MatRipple} from '../ripple/ripple';
|
||
|
|
import {MatPseudoCheckbox} from '../selection/pseudo-checkbox/pseudo-checkbox';
|
||
|
|
import {_StructuralStylesLoader} from '../focus-indicators/structural-styles';
|
||
|
|
import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/private';
|
||
|
|
|
||
|
|
/** Event object emitted by MatOption when selected or deselected. */
|
||
|
|
export class MatOptionSelectionChange<T = any> {
|
||
|
|
constructor(
|
||
|
|
/** Reference to the option that emitted the event. */
|
||
|
|
public source: MatOption<T>,
|
||
|
|
/** Whether the change in the option's value was a result of a user action. */
|
||
|
|
public isUserInput = false,
|
||
|
|
) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Single option inside of a `<mat-select>` element.
|
||
|
|
*/
|
||
|
|
@Component({
|
||
|
|
selector: 'mat-option',
|
||
|
|
exportAs: 'matOption',
|
||
|
|
host: {
|
||
|
|
'role': 'option',
|
||
|
|
'[class.mdc-list-item--selected]': 'selected',
|
||
|
|
'[class.mat-mdc-option-multiple]': 'multiple',
|
||
|
|
'[class.mat-mdc-option-active]': 'active',
|
||
|
|
'[class.mdc-list-item--disabled]': 'disabled',
|
||
|
|
'[id]': 'id',
|
||
|
|
// Set aria-selected to false for non-selected items and true for selected items. Conform to
|
||
|
|
// [WAI ARIA Listbox authoring practices guide](
|
||
|
|
// https://www.w3.org/WAI/ARIA/apg/patterns/listbox/), "If any options are selected, each
|
||
|
|
// selected option has either aria-selected or aria-checked set to true. All options that are
|
||
|
|
// selectable but not selected have either aria-selected or aria-checked set to false." Align
|
||
|
|
// aria-selected implementation of Chips and List components.
|
||
|
|
//
|
||
|
|
// Set `aria-selected="false"` on not-selected listbox options to fix VoiceOver announcing
|
||
|
|
// every option as "selected" (#21491).
|
||
|
|
'[attr.aria-selected]': 'selected',
|
||
|
|
'[attr.aria-disabled]': 'disabled.toString()',
|
||
|
|
'(click)': '_selectViaInteraction()',
|
||
|
|
'(keydown)': '_handleKeydown($event)',
|
||
|
|
'class': 'mat-mdc-option mdc-list-item',
|
||
|
|
},
|
||
|
|
styleUrl: 'option.css',
|
||
|
|
templateUrl: 'option.html',
|
||
|
|
encapsulation: ViewEncapsulation.None,
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
imports: [MatPseudoCheckbox, MatRipple],
|
||
|
|
})
|
||
|
|
export class MatOption<T = any> implements FocusableOption, AfterViewChecked, OnDestroy {
|
||
|
|
private _element = inject<ElementRef<HTMLElement>>(ElementRef);
|
||
|
|
_changeDetectorRef = inject(ChangeDetectorRef);
|
||
|
|
private _parent = inject<MatOptionParentComponent>(MAT_OPTION_PARENT_COMPONENT, {optional: true});
|
||
|
|
group = inject<MatOptgroup>(MAT_OPTGROUP, {optional: true});
|
||
|
|
|
||
|
|
private _signalDisableRipple = false;
|
||
|
|
private _selected = false;
|
||
|
|
private _active = false;
|
||
|
|
private _disabled = false;
|
||
|
|
private _mostRecentViewValue = '';
|
||
|
|
|
||
|
|
/** Whether the wrapping component is in multiple selection mode. */
|
||
|
|
get multiple() {
|
||
|
|
return this._parent && this._parent.multiple;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether or not the option is currently selected. */
|
||
|
|
get selected(): boolean {
|
||
|
|
return this._selected;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The form value of the option. */
|
||
|
|
@Input() value: T;
|
||
|
|
|
||
|
|
/** The unique ID of the option. */
|
||
|
|
@Input() id: string = inject(_IdGenerator).getId('mat-option-');
|
||
|
|
|
||
|
|
/** Whether the option is disabled. */
|
||
|
|
@Input({transform: booleanAttribute})
|
||
|
|
get disabled(): boolean {
|
||
|
|
return (this.group && this.group.disabled) || this._disabled;
|
||
|
|
}
|
||
|
|
set disabled(value: boolean) {
|
||
|
|
this._disabled = value;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether ripples for the option are disabled. */
|
||
|
|
get disableRipple(): boolean {
|
||
|
|
return this._signalDisableRipple
|
||
|
|
? (this._parent!.disableRipple as Signal<boolean>)()
|
||
|
|
: !!this._parent?.disableRipple;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether to display checkmark for single-selection. */
|
||
|
|
get hideSingleSelectionIndicator(): boolean {
|
||
|
|
return !!(this._parent && this._parent.hideSingleSelectionIndicator);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Event emitted when the option is selected or deselected. */
|
||
|
|
// tslint:disable-next-line:no-output-on-prefix
|
||
|
|
@Output() readonly onSelectionChange = new EventEmitter<MatOptionSelectionChange<T>>();
|
||
|
|
|
||
|
|
/** Element containing the option's text. */
|
||
|
|
@ViewChild('text', {static: true}) _text: ElementRef<HTMLElement> | undefined;
|
||
|
|
|
||
|
|
/** Emits when the state of the option changes and any parents have to be notified. */
|
||
|
|
readonly _stateChanges = new Subject<void>();
|
||
|
|
|
||
|
|
constructor(...args: unknown[]);
|
||
|
|
constructor() {
|
||
|
|
inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
|
||
|
|
inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader);
|
||
|
|
this._signalDisableRipple = !!this._parent && isSignal(this._parent.disableRipple);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether or not the option is currently active and ready to be selected.
|
||
|
|
* An active option displays styles as if it is focused, but the
|
||
|
|
* focus is actually retained somewhere else. This comes in handy
|
||
|
|
* for components like autocomplete where focus must remain on the input.
|
||
|
|
*/
|
||
|
|
get active(): boolean {
|
||
|
|
return this._active;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The displayed value of the option. It is necessary to show the selected option in the
|
||
|
|
* select's trigger.
|
||
|
|
*/
|
||
|
|
get viewValue(): string {
|
||
|
|
// TODO(kara): Add input property alternative for node envs.
|
||
|
|
return (this._text?.nativeElement.textContent || '').trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Selects the option. */
|
||
|
|
select(emitEvent = true): void {
|
||
|
|
if (!this._selected) {
|
||
|
|
this._selected = true;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
|
||
|
|
if (emitEvent) {
|
||
|
|
this._emitSelectionChangeEvent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Deselects the option. */
|
||
|
|
deselect(emitEvent = true): void {
|
||
|
|
if (this._selected) {
|
||
|
|
this._selected = false;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
|
||
|
|
if (emitEvent) {
|
||
|
|
this._emitSelectionChangeEvent();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Sets focus onto this option. */
|
||
|
|
focus(_origin?: FocusOrigin, options?: FocusOptions): void {
|
||
|
|
// Note that we aren't using `_origin`, but we need to keep it because some internal consumers
|
||
|
|
// use `MatOption` in a `FocusKeyManager` and we need it to match `FocusableOption`.
|
||
|
|
const element = this._getHostElement();
|
||
|
|
|
||
|
|
if (typeof element.focus === 'function') {
|
||
|
|
element.focus(options);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* This method sets display styles on the option to make it appear
|
||
|
|
* active. This is used by the ActiveDescendantKeyManager so key
|
||
|
|
* events will display the proper options as active on arrow key events.
|
||
|
|
*/
|
||
|
|
setActiveStyles(): void {
|
||
|
|
if (!this._active) {
|
||
|
|
this._active = true;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* This method removes display styles on the option that made it appear
|
||
|
|
* active. This is used by the ActiveDescendantKeyManager so key
|
||
|
|
* events will display the proper options as active on arrow key events.
|
||
|
|
*/
|
||
|
|
setInactiveStyles(): void {
|
||
|
|
if (this._active) {
|
||
|
|
this._active = false;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Gets the label to be used when determining whether the option should be focused. */
|
||
|
|
getLabel(): string {
|
||
|
|
return this.viewValue;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Ensures the option is selected when activated from the keyboard. */
|
||
|
|
_handleKeydown(event: KeyboardEvent): void {
|
||
|
|
if ((event.keyCode === ENTER || event.keyCode === SPACE) && !hasModifierKey(event)) {
|
||
|
|
this._selectViaInteraction();
|
||
|
|
|
||
|
|
// Prevent the page from scrolling down and form submits.
|
||
|
|
event.preventDefault();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* `Selects the option while indicating the selection came from the user. Used to
|
||
|
|
* determine if the select's view -> model callback should be invoked.`
|
||
|
|
*/
|
||
|
|
_selectViaInteraction(): void {
|
||
|
|
if (!this.disabled) {
|
||
|
|
this._selected = this.multiple ? !this._selected : true;
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
this._emitSelectionChangeEvent(true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Returns the correct tabindex for the option depending on disabled state. */
|
||
|
|
// This method is only used by `MatLegacyOption`. Keeping it here to avoid breaking the types.
|
||
|
|
// That's because `MatLegacyOption` use `MatOption` type in a few places such as
|
||
|
|
// `MatOptionSelectionChange`. It is safe to delete this when `MatLegacyOption` is deleted.
|
||
|
|
_getTabIndex(): string {
|
||
|
|
return this.disabled ? '-1' : '0';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Gets the host DOM element. */
|
||
|
|
_getHostElement(): HTMLElement {
|
||
|
|
return this._element.nativeElement;
|
||
|
|
}
|
||
|
|
|
||
|
|
ngAfterViewChecked() {
|
||
|
|
// Since parent components could be using the option's label to display the selected values
|
||
|
|
// (e.g. `mat-select`) and they don't have a way of knowing if the option's label has changed
|
||
|
|
// we have to check for changes in the DOM ourselves and dispatch an event. These checks are
|
||
|
|
// relatively cheap, however we still limit them only to selected options in order to avoid
|
||
|
|
// hitting the DOM too often.
|
||
|
|
if (this._selected) {
|
||
|
|
const viewValue = this.viewValue;
|
||
|
|
|
||
|
|
if (viewValue !== this._mostRecentViewValue) {
|
||
|
|
if (this._mostRecentViewValue) {
|
||
|
|
this._stateChanges.next();
|
||
|
|
}
|
||
|
|
|
||
|
|
this._mostRecentViewValue = viewValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ngOnDestroy() {
|
||
|
|
this._stateChanges.complete();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Emits the selection change event. */
|
||
|
|
private _emitSelectionChangeEvent(isUserInput = false): void {
|
||
|
|
this.onSelectionChange.emit(new MatOptionSelectionChange<T>(this, isUserInput));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Counts the amount of option group labels that precede the specified option.
|
||
|
|
* @param optionIndex Index of the option at which to start counting.
|
||
|
|
* @param options Flat list of all of the options.
|
||
|
|
* @param optionGroups Flat list of all of the option groups.
|
||
|
|
* @docs-private
|
||
|
|
*/
|
||
|
|
export function _countGroupLabelsBeforeOption(
|
||
|
|
optionIndex: number,
|
||
|
|
options: QueryList<MatOption>,
|
||
|
|
optionGroups: QueryList<MatOptgroup>,
|
||
|
|
): number {
|
||
|
|
if (optionGroups.length) {
|
||
|
|
let optionsArray = options.toArray();
|
||
|
|
let groups = optionGroups.toArray();
|
||
|
|
let groupCounter = 0;
|
||
|
|
|
||
|
|
for (let i = 0; i < optionIndex + 1; i++) {
|
||
|
|
if (optionsArray[i].group && optionsArray[i].group === groups[groupCounter]) {
|
||
|
|
groupCounter++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return groupCounter;
|
||
|
|
}
|
||
|
|
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determines the position to which to scroll a panel in order for an option to be into view.
|
||
|
|
* @param optionOffset Offset of the option from the top of the panel.
|
||
|
|
* @param optionHeight Height of the options.
|
||
|
|
* @param currentScrollPosition Current scroll position of the panel.
|
||
|
|
* @param panelHeight Height of the panel.
|
||
|
|
* @docs-private
|
||
|
|
*/
|
||
|
|
export function _getOptionScrollPosition(
|
||
|
|
optionOffset: number,
|
||
|
|
optionHeight: number,
|
||
|
|
currentScrollPosition: number,
|
||
|
|
panelHeight: number,
|
||
|
|
): number {
|
||
|
|
if (optionOffset < currentScrollPosition) {
|
||
|
|
return optionOffset;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (optionOffset + optionHeight > currentScrollPosition + panelHeight) {
|
||
|
|
return Math.max(0, optionOffset - panelHeight + optionHeight);
|
||
|
|
}
|
||
|
|
|
||
|
|
return currentScrollPosition;
|
||
|
|
}
|