sass-references/angular-material/material/dialog/dialog-container.ts

290 lines
11 KiB
TypeScript
Raw Normal View History

2024-12-06 10:42:08 +08:00
/**
* @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 {
ChangeDetectionStrategy,
Component,
ComponentRef,
EventEmitter,
OnDestroy,
ViewEncapsulation,
ANIMATION_MODULE_TYPE,
inject,
} from '@angular/core';
import {MatDialogConfig} from './dialog-config';
import {CdkDialogContainer} from '@angular/cdk/dialog';
import {coerceNumberProperty} from '@angular/cdk/coercion';
import {CdkPortalOutlet, ComponentPortal} from '@angular/cdk/portal';
/** Event that captures the state of dialog container animations. */
interface LegacyDialogAnimationEvent {
state: 'opened' | 'opening' | 'closing' | 'closed';
totalTime: number;
}
/** Class added when the dialog is open. */
const OPEN_CLASS = 'mdc-dialog--open';
/** Class added while the dialog is opening. */
const OPENING_CLASS = 'mdc-dialog--opening';
/** Class added while the dialog is closing. */
const CLOSING_CLASS = 'mdc-dialog--closing';
/** Duration of the opening animation in milliseconds. */
export const OPEN_ANIMATION_DURATION = 150;
/** Duration of the closing animation in milliseconds. */
export const CLOSE_ANIMATION_DURATION = 75;
@Component({
selector: 'mat-dialog-container',
templateUrl: 'dialog-container.html',
styleUrl: 'dialog.css',
encapsulation: ViewEncapsulation.None,
// Disabled for consistency with the non-MDC dialog container.
// tslint:disable-next-line:validate-decorators
changeDetection: ChangeDetectionStrategy.Default,
imports: [CdkPortalOutlet],
host: {
'class': 'mat-mdc-dialog-container mdc-dialog',
'tabindex': '-1',
'[attr.aria-modal]': '_config.ariaModal',
'[id]': '_config.id',
'[attr.role]': '_config.role',
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledByQueue[0]',
'[attr.aria-label]': '_config.ariaLabel',
'[attr.aria-describedby]': '_config.ariaDescribedBy || null',
'[class._mat-animation-noopable]': '!_animationsEnabled',
'[class.mat-mdc-dialog-container-with-actions]': '_actionSectionCount > 0',
},
})
export class MatDialogContainer extends CdkDialogContainer<MatDialogConfig> implements OnDestroy {
private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
/** Emits when an animation state changes. */
_animationStateChanged = new EventEmitter<LegacyDialogAnimationEvent>();
/** Whether animations are enabled. */
_animationsEnabled: boolean = this._animationMode !== 'NoopAnimations';
/** Number of actions projected in the dialog. */
protected _actionSectionCount = 0;
/** Host element of the dialog container component. */
private _hostElement: HTMLElement = this._elementRef.nativeElement;
/** Duration of the dialog open animation. */
private _enterAnimationDuration = this._animationsEnabled
? parseCssTime(this._config.enterAnimationDuration) ?? OPEN_ANIMATION_DURATION
: 0;
/** Duration of the dialog close animation. */
private _exitAnimationDuration = this._animationsEnabled
? parseCssTime(this._config.exitAnimationDuration) ?? CLOSE_ANIMATION_DURATION
: 0;
/** Current timer for dialog animations. */
private _animationTimer: ReturnType<typeof setTimeout> | null = null;
protected override _contentAttached(): void {
// Delegate to the original dialog-container initialization (i.e. saving the
// previous element, setting up the focus trap and moving focus to the container).
super._contentAttached();
// Note: Usually we would be able to use the MDC dialog foundation here to handle
// the dialog animation for us, but there are a few reasons why we just leverage
// their styles and not use the runtime foundation code:
// 1. Foundation does not allow us to disable animations.
// 2. Foundation contains unnecessary features we don't need and aren't
// tree-shakeable. e.g. background scrim, keyboard event handlers for ESC button.
this._startOpenAnimation();
}
/** Starts the dialog open animation if enabled. */
private _startOpenAnimation() {
this._animationStateChanged.emit({state: 'opening', totalTime: this._enterAnimationDuration});
if (this._animationsEnabled) {
this._hostElement.style.setProperty(
TRANSITION_DURATION_PROPERTY,
`${this._enterAnimationDuration}ms`,
);
// We need to give the `setProperty` call from above some time to be applied.
// One would expect that the open class is added once the animation finished, but MDC
// uses the open class in combination with the opening class to start the animation.
this._requestAnimationFrame(() => this._hostElement.classList.add(OPENING_CLASS, OPEN_CLASS));
this._waitForAnimationToComplete(this._enterAnimationDuration, this._finishDialogOpen);
} else {
this._hostElement.classList.add(OPEN_CLASS);
// Note: We could immediately finish the dialog opening here with noop animations,
// but we defer until next tick so that consumers can subscribe to `afterOpened`.
// Executing this immediately would mean that `afterOpened` emits synchronously
// on `dialog.open` before the consumer had a change to subscribe to `afterOpened`.
Promise.resolve().then(() => this._finishDialogOpen());
}
}
/**
* Starts the exit animation of the dialog if enabled. This method is
* called by the dialog ref.
*/
_startExitAnimation(): void {
this._animationStateChanged.emit({state: 'closing', totalTime: this._exitAnimationDuration});
this._hostElement.classList.remove(OPEN_CLASS);
if (this._animationsEnabled) {
this._hostElement.style.setProperty(
TRANSITION_DURATION_PROPERTY,
`${this._exitAnimationDuration}ms`,
);
// We need to give the `setProperty` call from above some time to be applied.
this._requestAnimationFrame(() => this._hostElement.classList.add(CLOSING_CLASS));
this._waitForAnimationToComplete(this._exitAnimationDuration, this._finishDialogClose);
} else {
// This subscription to the `OverlayRef#backdropClick` observable in the `DialogRef` is
// set up before any user can subscribe to the backdrop click. The subscription triggers
// the dialog close and this method synchronously. If we'd synchronously emit the `CLOSED`
// animation state event if animations are disabled, the overlay would be disposed
// immediately and all other subscriptions to `DialogRef#backdropClick` would be silently
// skipped. We work around this by waiting with the dialog close until the next tick when
// all subscriptions have been fired as expected. This is not an ideal solution, but
// there doesn't seem to be any other good way. Alternatives that have been considered:
// 1. Deferring `DialogRef.close`. This could be a breaking change due to a new microtask.
// Also this issue is specific to the MDC implementation where the dialog could
// technically be closed synchronously. In the non-MDC one, Angular animations are used
// and closing always takes at least a tick.
// 2. Ensuring that user subscriptions to `backdropClick`, `keydownEvents` in the dialog
// ref are first. This would solve the issue, but has the risk of memory leaks and also
// doesn't solve the case where consumers call `DialogRef.close` in their subscriptions.
// Based on the fact that this is specific to the MDC-based implementation of the dialog
// animations, the defer is applied here.
Promise.resolve().then(() => this._finishDialogClose());
}
}
/**
* Updates the number action sections.
* @param delta Increase/decrease in the number of sections.
*/
_updateActionSectionCount(delta: number) {
this._actionSectionCount += delta;
this._changeDetectorRef.markForCheck();
}
/**
* Completes the dialog open by clearing potential animation classes, trapping
* focus and emitting an opened event.
*/
private _finishDialogOpen = () => {
this._clearAnimationClasses();
this._openAnimationDone(this._enterAnimationDuration);
};
/**
* Completes the dialog close by clearing potential animation classes, restoring
* focus and emitting a closed event.
*/
private _finishDialogClose = () => {
this._clearAnimationClasses();
this._animationStateChanged.emit({state: 'closed', totalTime: this._exitAnimationDuration});
};
/** Clears all dialog animation classes. */
private _clearAnimationClasses() {
this._hostElement.classList.remove(OPENING_CLASS, CLOSING_CLASS);
}
private _waitForAnimationToComplete(duration: number, callback: () => void) {
if (this._animationTimer !== null) {
clearTimeout(this._animationTimer);
}
// Note that we want this timer to run inside the NgZone, because we want
// the related events like `afterClosed` to be inside the zone as well.
this._animationTimer = setTimeout(callback, duration);
}
/** Runs a callback in `requestAnimationFrame`, if available. */
private _requestAnimationFrame(callback: () => void) {
this._ngZone.runOutsideAngular(() => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(callback);
} else {
callback();
}
});
}
protected override _captureInitialFocus(): void {
if (!this._config.delayFocusTrap) {
this._trapFocus();
}
}
/**
* Callback for when the open dialog animation has finished. Intended to
* be called by sub-classes that use different animation implementations.
*/
protected _openAnimationDone(totalTime: number) {
if (this._config.delayFocusTrap) {
this._trapFocus();
}
this._animationStateChanged.next({state: 'opened', totalTime});
}
override ngOnDestroy() {
super.ngOnDestroy();
if (this._animationTimer !== null) {
clearTimeout(this._animationTimer);
}
}
override attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
// When a component is passed into the dialog, the host element interrupts
// the `display:flex` from affecting the dialog title, content, and
// actions. To fix this, we make the component host `display: contents` by
// marking its host with the `mat-mdc-dialog-component-host` class.
//
// Note that this problem does not exist when a template ref is used since
// the title, contents, and actions are then nested directly under the
// dialog surface.
const ref = super.attachComponentPortal(portal);
ref.location.nativeElement.classList.add('mat-mdc-dialog-component-host');
return ref;
}
}
const TRANSITION_DURATION_PROPERTY = '--mat-dialog-transition-duration';
// TODO(mmalerba): Remove this function after animation durations are required
// to be numbers.
/**
* Converts a CSS time string to a number in ms. If the given time is already a
* number, it is assumed to be in ms.
*/
function parseCssTime(time: string | number | undefined): number | null {
if (time == null) {
return null;
}
if (typeof time === 'number') {
return time;
}
if (time.endsWith('ms')) {
return coerceNumberProperty(time.substring(0, time.length - 2));
}
if (time.endsWith('s')) {
return coerceNumberProperty(time.substring(0, time.length - 1)) * 1000;
}
if (time === '0') {
return 0;
}
return null; // anything else is invalid.
}