diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts
index e9c471ac512d..a7933dc59a06 100644
--- a/src/cdk/overlay/overlay-directives.ts
+++ b/src/cdk/overlay/overlay-directives.ts
@@ -106,7 +106,7 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
private _overlay = inject(Overlay);
private _dir = inject(Directionality, {optional: true});
- private _overlayRef: OverlayRef;
+ private _overlayRef: OverlayRef | undefined;
private _templatePortal: TemplatePortal;
private _backdropSubscription = Subscription.EMPTY;
private _attachSubscription = Subscription.EMPTY;
@@ -251,7 +251,7 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
/** The associated overlay reference. */
get overlayRef(): OverlayRef {
- return this._overlayRef;
+ return this._overlayRef!;
}
/** The element's layout direction. */
@@ -264,16 +264,13 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
this._detachSubscription.unsubscribe();
this._backdropSubscription.unsubscribe();
this._positionSubscription.unsubscribe();
-
- if (this._overlayRef) {
- this._overlayRef.dispose();
- }
+ this._overlayRef?.dispose();
}
ngOnChanges(changes: SimpleChanges) {
if (this._position) {
this._updatePositionStrategy(this._position);
- this._overlayRef.updateSize({
+ this._overlayRef?.updateSize({
width: this.width,
minWidth: this.minWidth,
height: this.height,
@@ -286,7 +283,7 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
}
if (changes['open']) {
- this.open ? this._attachOverlay() : this._detachOverlay();
+ this.open ? this.attachOverlay() : this.detachOverlay();
}
}
@@ -304,7 +301,7 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
if (event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event)) {
event.preventDefault();
- this._detachOverlay();
+ this.detachOverlay();
}
});
@@ -411,8 +408,8 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
return null;
}
- /** Attaches the overlay and subscribes to backdrop clicks if backdrop exists */
- private _attachOverlay() {
+ /** Attaches the overlay. */
+ attachOverlay() {
if (!this._overlayRef) {
this._createOverlay();
} else {
@@ -420,12 +417,12 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
this._overlayRef.getConfig().hasBackdrop = this.hasBackdrop;
}
- if (!this._overlayRef.hasAttached()) {
- this._overlayRef.attach(this._templatePortal);
+ if (!this._overlayRef!.hasAttached()) {
+ this._overlayRef!.attach(this._templatePortal);
}
if (this.hasBackdrop) {
- this._backdropSubscription = this._overlayRef.backdropClick().subscribe(event => {
+ this._backdropSubscription = this._overlayRef!.backdropClick().subscribe(event => {
this.backdropClick.emit(event);
});
} else {
@@ -447,16 +444,16 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges {
}
});
}
- }
- /** Detaches the overlay and unsubscribes to backdrop clicks if backdrop exists */
- private _detachOverlay() {
- if (this._overlayRef) {
- this._overlayRef.detach();
- }
+ this.open = true;
+ }
+ /** Detaches the overlay. */
+ detachOverlay() {
+ this._overlayRef?.detach();
this._backdropSubscription.unsubscribe();
this._positionSubscription.unsubscribe();
+ this.open = false;
}
}
diff --git a/src/material/select/select-animations.ts b/src/material/select/select-animations.ts
index a2837ef3a716..f66b260d6ef6 100644
--- a/src/material/select/select-animations.ts
+++ b/src/material/select/select-animations.ts
@@ -23,6 +23,8 @@ import {
*
* The values below match the implementation of the AngularJS Material mat-select animation.
* @docs-private
+ * @deprecated No longer used, will be removed.
+ * @breaking-change 21.0.0
*/
export const matSelectAnimations: {
/**
diff --git a/src/material/select/select.html b/src/material/select/select.html
index 44d4f11e4067..abe42e0211c2 100644
--- a/src/material/select/select.html
+++ b/src/material/select/select.html
@@ -33,27 +33,25 @@
cdkConnectedOverlayLockPosition
cdkConnectedOverlayHasBackdrop
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
+ [cdkConnectedOverlayDisableClose]="true"
[cdkConnectedOverlayPanelClass]="_overlayPanelClass"
[cdkConnectedOverlayScrollStrategy]="_scrollStrategy"
[cdkConnectedOverlayOrigin]="_preferredOverlayOrigin || fallbackOverlayOrigin"
- [cdkConnectedOverlayOpen]="panelOpen"
[cdkConnectedOverlayPositions]="_positions"
[cdkConnectedOverlayWidth]="_overlayWidth"
(backdropClick)="close()"
- (attach)="_onAttached()"
- (detach)="close()">
+ (overlayKeydown)="_handleOverlayKeydown($event)">
diff --git a/src/material/select/select.scss b/src/material/select/select.scss
index aeb1247b0f77..7636c4b2e152 100644
--- a/src/material/select/select.scss
+++ b/src/material/select/select.scss
@@ -13,6 +13,27 @@ $mat-select-placeholder-arrow-space: 2 *
$leading-width: 12px !default;
$scale: 0.75 !default;
+@keyframes _mat-select-enter {
+ from {
+ opacity: 0;
+ transform: scaleY(0.8);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes _mat-select-exit {
+ from {
+ opacity: 1;
+ }
+
+ to {
+ opacity: 0;
+ }
+}
.mat-mdc-select {
display: inline-block;
@@ -173,6 +194,14 @@ div.mat-mdc-select-panel {
}
}
+.mat-select-panel-animations-enabled {
+ animation: _mat-select-enter 120ms cubic-bezier(0, 0, 0.2, 1);
+
+ &.mat-select-panel-exit {
+ animation: _mat-select-exit 100ms linear;
+ }
+}
+
.mat-mdc-select-placeholder {
// Delay the transition until the label has animated about a third of the way through, in
// order to prevent the placeholder from overlapping for a split second.
diff --git a/src/material/select/select.ts b/src/material/select/select.ts
index e99ce6b7d40b..c95ed420af56 100644
--- a/src/material/select/select.ts
+++ b/src/material/select/select.ts
@@ -19,6 +19,7 @@ import {
A,
DOWN_ARROW,
ENTER,
+ ESCAPE,
hasModifierKey,
LEFT_ARROW,
RIGHT_ARROW,
@@ -58,6 +59,9 @@ import {
ViewChild,
ViewEncapsulation,
HostAttributeToken,
+ ANIMATION_MODULE_TYPE,
+ Renderer2,
+ NgZone,
} from '@angular/core';
import {
AbstractControl,
@@ -80,16 +84,7 @@ import {
} from '@angular/material/core';
import {MAT_FORM_FIELD, MatFormField, MatFormFieldControl} from '@angular/material/form-field';
import {defer, merge, Observable, Subject} from 'rxjs';
-import {
- distinctUntilChanged,
- filter,
- map,
- startWith,
- switchMap,
- take,
- takeUntil,
-} from 'rxjs/operators';
-import {matSelectAnimations} from './select-animations';
+import {filter, map, startWith, switchMap, take, takeUntil} from 'rxjs/operators';
import {
getMatSelectDynamicMultipleError,
getMatSelectNonArrayValueError,
@@ -199,7 +194,6 @@ export class MatSelectChange {
'(focus)': '_onFocus()',
'(blur)': '_onBlur()',
},
- animations: [matSelectAnimations.transformPanel],
providers: [
{provide: MatFormFieldControl, useExisting: MatSelect},
{provide: MAT_OPTION_PARENT_COMPONENT, useExisting: MatSelect},
@@ -221,11 +215,16 @@ export class MatSelect
readonly _elementRef = inject(ElementRef);
private _dir = inject(Directionality, {optional: true});
private _idGenerator = inject(_IdGenerator);
+ private _renderer = inject(Renderer2);
+ private _ngZone = inject(NgZone);
protected _parentFormField = inject(MAT_FORM_FIELD, {optional: true});
ngControl = inject(NgControl, {self: true, optional: true})!;
private _liveAnnouncer = inject(LiveAnnouncer);
protected _defaultOptions = inject(MAT_SELECT_CONFIG, {optional: true});
+ protected _animationsDisabled =
+ inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
private _initialized = new Subject();
+ private _cleanupDetach: (() => void) | undefined;
/** All of the defined select options. */
@ContentChildren(MatOption, {descendants: true}) options: QueryList;
@@ -375,9 +374,6 @@ export class MatSelect
/** ID for the DOM node containing the select's value. */
_valueId = this._idGenerator.getId('mat-select-value-');
- /** Emits when the panel element is finished transforming in. */
- readonly _panelDoneAnimatingStream = new Subject();
-
/** Strategy that will be used to handle scrolling while the select panel is open. */
_scrollStrategy: ScrollStrategy;
@@ -643,14 +639,6 @@ export class MatSelect
ngOnInit() {
this._selectionModel = new SelectionModel(this.multiple);
this.stateChanges.next();
-
- // We need `distinctUntilChanged` here, because some browsers will
- // fire the animation end event twice for the same animation. See:
- // https://github.com/angular/angular/issues/24084
- this._panelDoneAnimatingStream
- .pipe(distinctUntilChanged(), takeUntil(this._destroy))
- .subscribe(() => this._panelDoneAnimating(this.panelOpen));
-
this._viewportRuler
.change()
.pipe(takeUntil(this._destroy))
@@ -727,6 +715,7 @@ export class MatSelect
}
ngOnDestroy() {
+ this._cleanupDetach?.();
this._keyManager?.destroy();
this._destroy.next();
this._destroy.complete();
@@ -752,15 +741,24 @@ export class MatSelect
this._preferredOverlayOrigin = this._parentFormField.getConnectedOverlayOrigin();
}
+ this._cleanupDetach?.();
this._overlayWidth = this._getOverlayWidth(this._preferredOverlayOrigin);
this._applyModalPanelOwnership();
this._panelOpen = true;
+ this._overlayDir.positionChange.pipe(take(1)).subscribe(() => {
+ this._changeDetectorRef.detectChanges();
+ this._positioningSettled();
+ });
+ this._overlayDir.attachOverlay();
this._keyManager.withHorizontalOrientation(null);
this._highlightCorrectOption();
this._changeDetectorRef.markForCheck();
// Required for the MDC form field to pick up when the overlay has been opened.
this.stateChanges.next();
+
+ // Simulate the animation event before we moved away from `@angular/animations`.
+ Promise.resolve().then(() => this.openedChange.emit(true));
}
/**
@@ -832,14 +830,52 @@ export class MatSelect
close(): void {
if (this._panelOpen) {
this._panelOpen = false;
+ this._exitAndDetach();
this._keyManager.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr');
this._changeDetectorRef.markForCheck();
this._onTouched();
// Required for the MDC form field to pick up when the overlay has been closed.
this.stateChanges.next();
+
+ // Simulate the animation event before we moved away from `@angular/animations`.
+ Promise.resolve().then(() => this.openedChange.emit(false));
}
}
+ /** Triggers the exit animation and detaches the overlay at the end. */
+ private _exitAndDetach() {
+ if (this._animationsDisabled) {
+ this._overlayDir.detachOverlay();
+ return;
+ }
+
+ this._ngZone.runOutsideAngular(() => {
+ this._cleanupDetach?.();
+ this._cleanupDetach = () => {
+ cleanupEvent();
+ clearTimeout(exitFallbackTimer);
+ this._cleanupDetach = undefined;
+ };
+
+ const panel: HTMLElement = this.panel.nativeElement;
+ const cleanupEvent = this._renderer.listen(panel, 'animationend', (event: AnimationEvent) => {
+ if (event.animationName === '_mat-select-exit') {
+ this._cleanupDetach?.();
+ this._overlayDir.detachOverlay();
+ }
+ });
+
+ // Since closing the overlay depends on the animation, we have a fallback in case the panel
+ // doesn't animate. This can happen in some internal tests that do `* {animation: none}`.
+ const exitFallbackTimer = setTimeout(() => {
+ this._cleanupDetach?.();
+ this._overlayDir.detachOverlay();
+ }, 200);
+
+ panel.classList.add('mat-select-panel-exit');
+ });
+ }
+
/**
* Sets the select's value. Part of the ControlValueAccessor interface
* required to integrate with Angular's core forms API.
@@ -1010,6 +1046,18 @@ export class MatSelect
}
}
+ /** Handles keyboard events coming from the overlay. */
+ protected _handleOverlayKeydown(event: KeyboardEvent): void {
+ // TODO(crisbeto): prior to #30363 this was being handled inside the overlay directive, but we
+ // need control over the animation timing so we do it manually. We should remove the `keydown`
+ // listener from `.mat-mdc-select-panel` and handle all the events here. That may cause
+ // further test breakages so it's left for a follow-up.
+ if (event.keyCode === ESCAPE && !hasModifierKey(event)) {
+ event.preventDefault();
+ this.close();
+ }
+ }
+
_onFocus() {
if (!this.disabled) {
this._focused = true;
@@ -1032,16 +1080,6 @@ export class MatSelect
}
}
- /**
- * Callback that is invoked when the overlay panel has been attached.
- */
- _onAttached(): void {
- this._overlayDir.positionChange.pipe(take(1)).subscribe(() => {
- this._changeDetectorRef.detectChanges();
- this._positioningSettled();
- });
- }
-
/** Returns the theme to be used on the panel. */
_getPanelTheme(): string {
return this._parentFormField ? `mat-${this._parentFormField.color}` : '';
@@ -1356,7 +1394,7 @@ export class MatSelect
/** Whether the panel is allowed to open. */
protected _canOpen(): boolean {
- return !this._panelOpen && !this.disabled && this.options?.length > 0;
+ return !this._panelOpen && !this.disabled && this.options?.length > 0 && !!this._overlayDir;
}
/** Focuses the select element. */
@@ -1400,11 +1438,6 @@ export class MatSelect
return value;
}
- /** Called when the overlay panel is done animating. */
- protected _panelDoneAnimating(isOpen: boolean) {
- this.openedChange.emit(isOpen);
- }
-
/**
* Implemented as part of MatFormFieldControl.
* @docs-private
diff --git a/tools/public_api_guard/cdk/overlay.md b/tools/public_api_guard/cdk/overlay.md
index 38eb6af08fac..7fc52a2baed2 100644
--- a/tools/public_api_guard/cdk/overlay.md
+++ b/tools/public_api_guard/cdk/overlay.md
@@ -46,9 +46,11 @@ export class BlockScrollStrategy implements ScrollStrategy {
export class CdkConnectedOverlay implements OnDestroy, OnChanges {
constructor(...args: unknown[]);
readonly attach: EventEmitter;
+ attachOverlay(): void;
backdropClass: string | string[];
readonly backdropClick: EventEmitter;
readonly detach: EventEmitter;
+ detachOverlay(): void;
get dir(): Direction;
disableClose: boolean;
get disposeOnNavigation(): boolean;
diff --git a/tools/public_api_guard/material/select.md b/tools/public_api_guard/material/select.md
index a39d47dbd3d6..981e927c6aff 100644
--- a/tools/public_api_guard/material/select.md
+++ b/tools/public_api_guard/material/select.md
@@ -81,6 +81,8 @@ export { MatPrefix }
// @public (undocumented)
export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit, DoCheck, ControlValueAccessor, MatFormFieldControl {
constructor(...args: unknown[]);
+ // (undocumented)
+ protected _animationsDisabled: boolean;
ariaLabel: string;
ariaLabelledby: string;
protected _canOpen(): boolean;
@@ -113,6 +115,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
_getPanelAriaLabelledby(): string | null;
_getPanelTheme(): string;
_handleKeydown(event: KeyboardEvent): void;
+ protected _handleOverlayKeydown(event: KeyboardEvent): void;
get hideSingleSelectionIndicator(): boolean;
set hideSingleSelectionIndicator(value: boolean);
get id(): string;
@@ -151,7 +154,6 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
- _onAttached(): void;
_onBlur(): void;
_onChange: (value: any) => void;
onContainerClick(): void;
@@ -172,8 +174,6 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
panelClass: string | string[] | Set | {
[key: string]: any;
};
- protected _panelDoneAnimating(isOpen: boolean): void;
- readonly _panelDoneAnimatingStream: Subject;
get panelOpen(): boolean;
panelWidth: string | number | null;
// (undocumented)
@@ -217,7 +217,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
static ɵfac: i0.ɵɵFactoryDeclaration;
}
-// @public
+// @public @deprecated
export const matSelectAnimations: {
readonly transformPanelWrap: AnimationTriggerMetadata;
readonly transformPanel: AnimationTriggerMetadata;