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;