From 1abf235d8a1cbf598e6b63fbec5a3200df754715 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 2 Apr 2020 11:55:08 +0200 Subject: [PATCH 1/3] chore: initial boilerplate for mdc-based dialog --- .../mdc-dialog/BUILD.bazel | 89 +++++++++++++++++++ .../mdc-dialog/README.md | 1 + .../mdc-dialog/_dialog-theme.scss | 11 +++ .../mdc-dialog/dialog.e2e.spec.ts | 0 .../mdc-dialog/dialog.html | 0 .../mdc-dialog/dialog.scss | 0 .../mdc-dialog/dialog.spec.ts | 1 + .../mdc-dialog/dialog.ts | 9 ++ src/material-experimental/mdc-dialog/index.ts | 9 ++ .../mdc-dialog/module.ts | 19 ++++ .../mdc-dialog/public-api.ts | 10 +++ 11 files changed, 149 insertions(+) create mode 100644 src/material-experimental/mdc-dialog/BUILD.bazel create mode 100644 src/material-experimental/mdc-dialog/README.md create mode 100644 src/material-experimental/mdc-dialog/_dialog-theme.scss create mode 100644 src/material-experimental/mdc-dialog/dialog.e2e.spec.ts create mode 100644 src/material-experimental/mdc-dialog/dialog.html create mode 100644 src/material-experimental/mdc-dialog/dialog.scss create mode 100644 src/material-experimental/mdc-dialog/dialog.spec.ts create mode 100644 src/material-experimental/mdc-dialog/dialog.ts create mode 100644 src/material-experimental/mdc-dialog/index.ts create mode 100644 src/material-experimental/mdc-dialog/module.ts create mode 100644 src/material-experimental/mdc-dialog/public-api.ts diff --git a/src/material-experimental/mdc-dialog/BUILD.bazel b/src/material-experimental/mdc-dialog/BUILD.bazel new file mode 100644 index 000000000000..309b6993515e --- /dev/null +++ b/src/material-experimental/mdc-dialog/BUILD.bazel @@ -0,0 +1,89 @@ +package(default_visibility = ["//visibility:public"]) + +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") +load( + "//tools:defaults.bzl", + "ng_e2e_test_library", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_binary", + "sass_library", +) + +ng_module( + name = "mdc-dialog", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":dialog_scss"] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-dialog", + deps = [ + "//src/material/core", + "@npm//@material/dialog", + ], +) + +sass_library( + name = "mdc_dialog_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + ], +) + +sass_binary( + name = "dialog_scss", + src = "dialog.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +########### +# Testing +########### + +ng_test_library( + name = "dialog_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":mdc-dialog", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + static_files = [ + "@npm//:node_modules/@material/dialog/dist/mdc.dialog.js", + ], + deps = [ + ":dialog_tests_lib", + "//src/material-experimental:mdc_require_config.js", + ], +) + +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + "//src/cdk/testing/private/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/testing/private/e2e", + ], +) diff --git a/src/material-experimental/mdc-dialog/README.md b/src/material-experimental/mdc-dialog/README.md new file mode 100644 index 000000000000..7a05e03a9161 --- /dev/null +++ b/src/material-experimental/mdc-dialog/README.md @@ -0,0 +1 @@ +This is a placeholder for the MDC-based implementation of dialog. diff --git a/src/material-experimental/mdc-dialog/_dialog-theme.scss b/src/material-experimental/mdc-dialog/_dialog-theme.scss new file mode 100644 index 000000000000..4aa83224770b --- /dev/null +++ b/src/material-experimental/mdc-dialog/_dialog-theme.scss @@ -0,0 +1,11 @@ +@import '../mdc-helpers/mdc-helpers'; +@import '@material/dialog/mixins.import'; + +@mixin mat-mdc-dialog-theme($theme) { + @include mat-using-mdc-theme($theme) { +} + +@mixin mat-mdc-dialog-typography($config) { + @include mat-using-mdc-typography($config) { + } +} diff --git a/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts b/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mdc-dialog/dialog.html b/src/material-experimental/mdc-dialog/dialog.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mdc-dialog/dialog.scss b/src/material-experimental/mdc-dialog/dialog.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mdc-dialog/dialog.spec.ts b/src/material-experimental/mdc-dialog/dialog.spec.ts new file mode 100644 index 000000000000..43745b8d8e5a --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog.spec.ts @@ -0,0 +1 @@ +describe('MDC-based MatDialog', () => {}); diff --git a/src/material-experimental/mdc-dialog/dialog.ts b/src/material-experimental/mdc-dialog/dialog.ts new file mode 100644 index 000000000000..b819486a9fe4 --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog.ts @@ -0,0 +1,9 @@ +/** + * @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.io/license + */ + +export class MatDialog {} diff --git a/src/material-experimental/mdc-dialog/index.ts b/src/material-experimental/mdc-dialog/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-dialog/index.ts @@ -0,0 +1,9 @@ +/** + * @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.io/license + */ + +export * from './public-api'; diff --git a/src/material-experimental/mdc-dialog/module.ts b/src/material-experimental/mdc-dialog/module.ts new file mode 100644 index 000000000000..b205b4948a21 --- /dev/null +++ b/src/material-experimental/mdc-dialog/module.ts @@ -0,0 +1,19 @@ +/** + * @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.io/license + */ + +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatCommonModule} from '@angular/material/core'; + +@NgModule({ + imports: [MatCommonModule, CommonModule], + exports: [], + declarations: [], +}) +export class MatDialogModule { +} diff --git a/src/material-experimental/mdc-dialog/public-api.ts b/src/material-experimental/mdc-dialog/public-api.ts new file mode 100644 index 000000000000..972291486cb6 --- /dev/null +++ b/src/material-experimental/mdc-dialog/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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.io/license + */ + +export * from './dialog'; +export * from './module'; From 7430760e91ae6488d7cb7ff742fc54c1a7889e00 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 21 Apr 2020 10:37:24 +0200 Subject: [PATCH 2/3] feat(material-experimental): MDC-based version of dialog --- .github/CODEOWNERS | 2 + .../dialog/dialog-container.ts | 27 +- src/dev-app/BUILD.bazel | 1 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 1 + src/dev-app/mdc-dialog/BUILD.bazel | 29 + .../mdc-dialog/mdc-dialog-demo-module.ts | 36 + src/dev-app/mdc-dialog/mdc-dialog-demo.html | 137 ++ src/dev-app/mdc-dialog/mdc-dialog-demo.scss | 19 + src/dev-app/mdc-dialog/mdc-dialog-demo.ts | 232 ++ src/e2e-app/BUILD.bazel | 1 + src/e2e-app/e2e-app/e2e-app-layout.html | 1 + src/e2e-app/e2e-app/routes.ts | 2 + src/e2e-app/main-module.ts | 2 + .../mdc-dialog/mdc-dialog-e2e-module.ts | 18 + src/e2e-app/mdc-dialog/mdc-dialog-e2e.html | 5 + src/e2e-app/mdc-dialog/mdc-dialog-e2e.ts | 44 + src/material-experimental/config.bzl | 2 + .../mdc-dialog/BUILD.bazel | 17 +- .../mdc-dialog/_dialog-legacy-padding.scss | 33 + .../mdc-dialog/_dialog-theme.scss | 34 +- .../_mdc-dialog-structure-overrides.scss | 56 + .../mdc-dialog/dialog-container.html | 5 + .../mdc-dialog/dialog-container.ts | 185 ++ .../mdc-dialog/dialog-content-directives.ts | 161 ++ .../mdc-dialog/dialog-ref.ts | 26 + .../mdc-dialog/dialog.e2e.spec.ts | 105 + .../mdc-dialog/dialog.html | 0 .../mdc-dialog/dialog.scss | 60 + .../mdc-dialog/dialog.spec.ts | 1867 ++++++++++++++++- .../mdc-dialog/dialog.ts | 53 +- .../mdc-dialog/module.ts | 39 +- .../mdc-dialog/public-api.ts | 12 + .../mdc-dialog/testing/BUILD.bazel | 45 + .../mdc-dialog/testing/dialog-harness.spec.ts | 7 + .../mdc-dialog/testing/dialog-harness.ts | 29 + .../mdc-dialog/testing/index.ts | 9 + .../mdc-dialog/testing/public-api.ts | 10 + .../mdc-theming/BUILD.bazel | 1 + .../mdc-theming/_all-theme.scss | 2 + src/material/dialog/dialog-container.ts | 177 +- .../dialog/dialog-content-directives.ts | 10 +- src/material/dialog/dialog-ref.ts | 12 +- src/material/dialog/dialog.spec.ts | 4 +- src/material/dialog/dialog.ts | 91 +- .../dialog/testing/dialog-harness.spec.ts | 4 +- src/material/dialog/testing/shared.spec.ts | 28 +- .../kitchen-sink-mdc/kitchen-sink-mdc.ts | 15 +- tools/public_api_guard/material/dialog.d.ts | 77 +- 49 files changed, 3552 insertions(+), 182 deletions(-) create mode 100644 src/dev-app/mdc-dialog/BUILD.bazel create mode 100644 src/dev-app/mdc-dialog/mdc-dialog-demo-module.ts create mode 100644 src/dev-app/mdc-dialog/mdc-dialog-demo.html create mode 100644 src/dev-app/mdc-dialog/mdc-dialog-demo.scss create mode 100644 src/dev-app/mdc-dialog/mdc-dialog-demo.ts create mode 100644 src/e2e-app/mdc-dialog/mdc-dialog-e2e-module.ts create mode 100644 src/e2e-app/mdc-dialog/mdc-dialog-e2e.html create mode 100644 src/e2e-app/mdc-dialog/mdc-dialog-e2e.ts create mode 100644 src/material-experimental/mdc-dialog/_dialog-legacy-padding.scss create mode 100644 src/material-experimental/mdc-dialog/_mdc-dialog-structure-overrides.scss create mode 100644 src/material-experimental/mdc-dialog/dialog-container.html create mode 100644 src/material-experimental/mdc-dialog/dialog-container.ts create mode 100644 src/material-experimental/mdc-dialog/dialog-content-directives.ts create mode 100644 src/material-experimental/mdc-dialog/dialog-ref.ts delete mode 100644 src/material-experimental/mdc-dialog/dialog.html create mode 100644 src/material-experimental/mdc-dialog/testing/BUILD.bazel create mode 100644 src/material-experimental/mdc-dialog/testing/dialog-harness.spec.ts create mode 100644 src/material-experimental/mdc-dialog/testing/dialog-harness.ts create mode 100644 src/material-experimental/mdc-dialog/testing/index.ts create mode 100644 src/material-experimental/mdc-dialog/testing/public-api.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index aa027e915f88..ed2e825ca392 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -174,6 +174,7 @@ /src/dev-app/mdc-card/** @mmalerba /src/dev-app/mdc-checkbox/** @mmalerba /src/dev-app/mdc-chips/** @mmalerba +/src/dev-app/mdc-dialog/** @devversion /src/dev-app/mdc-input/** @devversion @mmalerba /src/dev-app/mdc-list/** @mmalerba /src/dev-app/mdc-menu/** @crisbeto @@ -234,6 +235,7 @@ /src/e2e-app/mdc-card/** @mmalerba /src/e2e-app/mdc-checkbox/** @mmalerba /src/e2e-app/mdc-chips/** @mmalerba +/src/e2e-app/mdc-dialog/** @devversion /src/e2e-app/mdc-input/** @devversion /src/e2e-app/mdc-menu/** @crisbeto /src/e2e-app/mdc-progress-bar/** @crisbeto diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 74e6aa680057..a3630250768f 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -17,6 +17,7 @@ import { } from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -72,7 +73,7 @@ export function throwDialogContentAlreadyAttachedError() { '(@dialog.done)': '_animationDone.next($event)', }, }) -export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { +export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy, AfterViewInit { private readonly _document: Document; /** State of the dialog animation. */ @@ -150,6 +151,16 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { }); } + /** If the dialog view completes initialization, the open animation starts. */ + ngAfterViewInit() { + // Save the previously focused element. This element will be re-focused + // when the dialog closes. + this._savePreviouslyFocusedElement(); + // Move focus onto the dialog immediately in order to prevent the user + // from accidentally opening multiple dialogs at the same time. + this._focusDialogContainer(); + } + /** Destroy focus trap to place focus back to the element focused before the dialog opened. */ ngOnDestroy() { this._focusTrap.destroy(); @@ -165,7 +176,6 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { throwDialogContentAlreadyAttachedError(); } - this._savePreviouslyFocusedElement(); return this._portalHost.attachComponentPortal(portal); } @@ -178,7 +188,6 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { throwDialogContentAlreadyAttachedError(); } - this._savePreviouslyFocusedElement(); return this._portalHost.attachTemplatePortal(portal); } @@ -193,7 +202,6 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { throwDialogContentAlreadyAttachedError(); } - this._savePreviouslyFocusedElement(); return this._portalHost.attachDomPortal(portal); } @@ -222,11 +230,14 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { private _savePreviouslyFocusedElement() { if (this._document) { this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement; + } + } - // Move focus onto the dialog immediately in order to prevent the user from accidentally - // opening multiple dialogs at the same time. Needs to be async, because the element - // may not be focusable immediately. - Promise.resolve().then(() => this._elementRef.nativeElement.focus()); + /** Focuses the dialog container. */ + private _focusDialogContainer() { + // Note that there is no focus method when rendering on the server. + if (this._elementRef.nativeElement.focus) { + this._elementRef.nativeElement.focus(); } } diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 31b817794910..3a121eac8de4 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -50,6 +50,7 @@ ng_module( "//src/dev-app/mdc-card", "//src/dev-app/mdc-checkbox", "//src/dev-app/mdc-chips", + "//src/dev-app/mdc-dialog", "//src/dev-app/mdc-input", "//src/dev-app/mdc-list", "//src/dev-app/mdc-menu", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index daf5413d0b9a..18d53543a0bb 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -78,6 +78,7 @@ export class DevAppLayout { {name: 'MDC Card', route: '/mdc-card'}, {name: 'MDC Checkbox', route: '/mdc-checkbox'}, {name: 'MDC Chips', route: '/mdc-chips'}, + {name: 'MDC Dialog', route: '/mdc-dialog'}, {name: 'MDC Input', route: '/mdc-input'}, {name: 'MDC List', route: '/mdc-list'}, {name: 'MDC Menu', route: '/mdc-menu'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index 8d0d05679590..3afe2724d056 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -80,6 +80,7 @@ export const DEV_APP_ROUTES: Routes = [ loadChildren: 'mdc-progress-bar/mdc-progress-bar-demo-module#MdcProgressBarDemoModule' }, {path: 'mdc-chips', loadChildren: 'mdc-chips/mdc-chips-demo-module#MdcChipsDemoModule'}, + {path: 'mdc-dialog', loadChildren: 'mdc-dialog/mdc-dialog-demo-module#MdcDialogDemoModule'}, {path: 'mdc-input', loadChildren: 'mdc-input/mdc-input-demo-module#MdcInputDemoModule'}, {path: 'mdc-list', loadChildren: 'mdc-list/mdc-list-demo-module#MdcListDemoModule'}, {path: 'mdc-menu', loadChildren: 'mdc-menu/mdc-menu-demo-module#MdcMenuDemoModule'}, diff --git a/src/dev-app/mdc-dialog/BUILD.bazel b/src/dev-app/mdc-dialog/BUILD.bazel new file mode 100644 index 000000000000..df2ee800e171 --- /dev/null +++ b/src/dev-app/mdc-dialog/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "mdc-dialog", + srcs = glob(["**/*.ts"]), + assets = [ + "mdc-dialog-demo.html", + ":mdc_dialog_demo_scss", + ], + deps = [ + "//src/material-experimental/mdc-dialog", + "//src/material/button", + "//src/material/card", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/input", + "//src/material/select", + "@npm//@angular/router", + ], +) + +sass_binary( + name = "mdc_dialog_demo_scss", + src = "mdc-dialog-demo.scss", + include_paths = ["external/npm/node_modules"], + deps = ["//src/material-experimental/mdc-dialog:mdc_dialog_scss_lib"], +) diff --git a/src/dev-app/mdc-dialog/mdc-dialog-demo-module.ts b/src/dev-app/mdc-dialog/mdc-dialog-demo-module.ts new file mode 100644 index 000000000000..3a47e066f23d --- /dev/null +++ b/src/dev-app/mdc-dialog/mdc-dialog-demo-module.ts @@ -0,0 +1,36 @@ +/** + * @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.io/license + */ + +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatDialogModule} from '@angular/material-experimental/mdc-dialog'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {RouterModule} from '@angular/router'; +import {ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog} from './mdc-dialog-demo'; + +@NgModule({ + imports: [ + FormsModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + RouterModule.forChild([{path: '', component: DialogDemo}]), + ], + declarations: [ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog], +}) +export class MdcDialogDemoModule { +} diff --git a/src/dev-app/mdc-dialog/mdc-dialog-demo.html b/src/dev-app/mdc-dialog/mdc-dialog-demo.html new file mode 100644 index 000000000000..8aa566b7d247 --- /dev/null +++ b/src/dev-app/mdc-dialog/mdc-dialog-demo.html @@ -0,0 +1,137 @@ +

Dialog demo

+ + + + + +

+ Enable legacy padding +

+ + + +

Dialog dimensions

+ +

+ + Width + + + + Height + + +

+ +

+ + Min width + + + + Min height + + +

+ +

+ + Max width + + + + Max height + + +

+ +

Dialog position

+ +

+ + Top + + + + Bottom + + +

+ +

+ + Left + + + + Right + + +

+ +

Dialog backdrop

+ +

+ + Backdrop class + + +

+ + Has backdrop + +

Other options

+ +

+ + Button alignment + + Start + End + Center + + +

+ +

+ + Dialog message + + +

+ +

+ Disable close +

+
+
+ +

Last afterClosed result: {{lastAfterClosedResult}}

+

Last beforeClose result: {{lastBeforeCloseResult}}

+ + + I'm a template dialog. I've been opened {{numTemplateOpens}} times! + +

It's Jazz!

+ + + How much? + + + +

{{ data.message }}

+ + +
diff --git a/src/dev-app/mdc-dialog/mdc-dialog-demo.scss b/src/dev-app/mdc-dialog/mdc-dialog-demo.scss new file mode 100644 index 000000000000..7be90d5ed90a --- /dev/null +++ b/src/dev-app/mdc-dialog/mdc-dialog-demo.scss @@ -0,0 +1,19 @@ +@import '../../material-experimental/mdc-dialog/dialog-legacy-padding'; + +.demo-dialog-card { + max-width: 405px; + margin: 20px 0; +} + +.demo-dialog-button { + margin-right: 8px; + + [dir='rtl'] & { + margin-left: 8px; + margin-right: 0; + } +} + +.demo-dialog-legacy-padding { + @include mat-mdc-dialog-legacy-padding(); +} diff --git a/src/dev-app/mdc-dialog/mdc-dialog-demo.ts b/src/dev-app/mdc-dialog/mdc-dialog-demo.ts new file mode 100644 index 000000000000..66a92d46011d --- /dev/null +++ b/src/dev-app/mdc-dialog/mdc-dialog-demo.ts @@ -0,0 +1,232 @@ +/** + * @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.io/license + */ + +import {DOCUMENT} from '@angular/common'; +import {Component, Inject, TemplateRef, ViewChild, ViewEncapsulation} from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogConfig, + MatDialogRef +} from '@angular/material-experimental/mdc-dialog'; + + +const defaultDialogConfig = new MatDialogConfig(); + +@Component({ + selector: 'mdc-dialog-demo', + templateUrl: 'mdc-dialog-demo.html', + styleUrls: ['mdc-dialog-demo.css'], + // View encapsulation is disabled since we add the legacy dialog padding + // styles that need to target the dialog (not only the projected content). + encapsulation: ViewEncapsulation.None, +}) +export class DialogDemo { + dialogRef: MatDialogRef | null; + lastAfterClosedResult: string; + lastBeforeCloseResult: string; + actionsAlignment: string; + config = { + disableClose: false, + panelClass: 'custom-overlay-pane-class', + hasBackdrop: true, + backdropClass: '', + width: '', + height: '', + minWidth: '', + minHeight: '', + maxWidth: defaultDialogConfig.maxWidth, + maxHeight: '', + position: { + top: '', + bottom: '', + left: '', + right: '' + }, + data: { + message: 'Jazzy jazz jazz' + } + }; + numTemplateOpens = 0; + enableLegacyPadding = false; + + @ViewChild(TemplateRef) template: TemplateRef; + + constructor(public dialog: MatDialog, @Inject(DOCUMENT) doc: any) { + // Possible useful example for the open and closeAll events. + // Adding a class to the body if a dialog opens and + // removing it after all open dialogs are closed + dialog.afterOpened.subscribe(() => { + if (!doc.body.classList.contains('no-scroll')) { + doc.body.classList.add('no-scroll'); + } + }); + dialog.afterAllClosed.subscribe(() => { + doc.body.classList.remove('no-scroll'); + }); + } + + openJazz() { + this.dialogRef = this.dialog.open(JazzDialog, this._getDialogConfig()); + + this.dialogRef.beforeClosed().subscribe((result: string) => { + this.lastBeforeCloseResult = result; + }); + this.dialogRef.afterClosed().subscribe((result: string) => { + this.lastAfterClosedResult = result; + this.dialogRef = null; + }); + } + + openContentElement() { + const dialogRef = this.dialog.open(ContentElementDialog, this._getDialogConfig()); + dialogRef.componentInstance.actionsAlignment = this.actionsAlignment; + } + + openTemplate() { + this.numTemplateOpens++; + this.dialog.open(this.template, this._getDialogConfig()); + } + + private _getDialogConfig(): MatDialogConfig { + const config = {...this.config}; + if (this.enableLegacyPadding) { + config.panelClass = `demo-dialog-legacy-padding`; + } + return config; + } +} + + +@Component({ + selector: 'demo-jazz-dialog', + template: ` +
+

It's Jazz!

+ + + How much? + + + +

{{ data.message }}

+ + + +
+ `, + encapsulation: ViewEncapsulation.None, + styles: [`.hidden-dialog { opacity: 0; }`] +}) +export class JazzDialog { + private _dimesionToggle = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any) { } + + togglePosition(): void { + this._dimesionToggle = !this._dimesionToggle; + + if (this._dimesionToggle) { + this.dialogRef + .updateSize('500px', '500px') + .updatePosition({ top: '25px', left: '25px' }); + } else { + this.dialogRef + .updateSize() + .updatePosition(); + } + } + + temporarilyHide(): void { + this.dialogRef.addPanelClass('hidden-dialog'); + setTimeout(() => { + this.dialogRef.removePanelClass('hidden-dialog'); + }, 2000); + } +} + + +@Component({ + selector: 'demo-content-element-dialog', + styles: [` + img { + max-width: 100%; + } + `], + template: ` +

Neptune

+ + + + +

+ Neptune is the eighth and farthest known planet from the Sun in the Solar System. In the + Solar System, it is the fourth-largest planet by diameter, the third-most-massive planet, + and the densest giant planet. Neptune is 17 times the mass of Earth and is slightly more + massive than its near-twin Uranus, which is 15 times the mass of Earth and slightly larger + than Neptune. Neptune orbits the Sun once every 164.8 years at an average distance of 30.1 + astronomical units (4.50×109 km). It is named after the Roman god of the sea and has the + astronomical symbol ♆, a stylised version of the god Neptune's trident. +

+
+ + + + + Read more on Wikipedia + + + + ` +}) +export class ContentElementDialog { + actionsAlignment: string; + + constructor(public dialog: MatDialog) { } + + showInStackedDialog() { + this.dialog.open(IFrameDialog); + } +} + +@Component({ + selector: 'demo-iframe-dialog', + styles: [` + iframe { + width: 800px; + } + `], + template: ` +

Neptune

+ + + + + + + + + ` +}) +export class IFrameDialog {} diff --git a/src/e2e-app/BUILD.bazel b/src/e2e-app/BUILD.bazel index 454af893fda4..5e68ad792f09 100644 --- a/src/e2e-app/BUILD.bazel +++ b/src/e2e-app/BUILD.bazel @@ -37,6 +37,7 @@ ng_module( "//src/material-experimental/mdc-card", "//src/material-experimental/mdc-checkbox", "//src/material-experimental/mdc-chips", + "//src/material-experimental/mdc-dialog", "//src/material-experimental/mdc-input", "//src/material-experimental/mdc-menu", "//src/material-experimental/mdc-progress-bar", diff --git a/src/e2e-app/e2e-app/e2e-app-layout.html b/src/e2e-app/e2e-app/e2e-app-layout.html index 36147be3f023..0dafe03e00c4 100644 --- a/src/e2e-app/e2e-app/e2e-app-layout.html +++ b/src/e2e-app/e2e-app/e2e-app-layout.html @@ -27,6 +27,7 @@ MDC Card MDC Checkbox MDC Chips + MDC Dialog MDC Input MDC Menu MDC Radio diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index a88f9fb64b84..d79c67b97353 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -15,6 +15,7 @@ import {MdcButtonE2e} from '../mdc-button/mdc-button-e2e'; import {MdcCardE2e} from '../mdc-card/mdc-card-e2e'; import {MdcCheckboxE2e} from '../mdc-checkbox/mdc-checkbox-e2e'; import {MdcChipsE2e} from '../mdc-chips/mdc-chips-e2e'; +import {MdcDialogE2E} from '../mdc-dialog/mdc-dialog-e2e'; import {MdcInputE2E} from '../mdc-input/mdc-input-e2e'; import {MdcMenuE2e} from '../mdc-menu/mdc-menu-e2e'; import {MdcRadioE2e} from '../mdc-radio/mdc-radio-e2e'; @@ -53,6 +54,7 @@ export const E2E_APP_ROUTES: Routes = [ {path: 'mdc-card', component: MdcCardE2e}, {path: 'mdc-checkbox', component: MdcCheckboxE2e}, {path: 'mdc-chips', component: MdcChipsE2e}, + {path: 'mdc-dialog', component: MdcDialogE2E}, {path: 'mdc-input', component: MdcInputE2E}, {path: 'mdc-menu', component: MdcMenuE2e}, {path: 'mdc-radio', component: MdcRadioE2e}, diff --git a/src/e2e-app/main-module.ts b/src/e2e-app/main-module.ts index b2bb0cc28d35..5af54fb61215 100644 --- a/src/e2e-app/main-module.ts +++ b/src/e2e-app/main-module.ts @@ -23,6 +23,7 @@ import {MdcButtonE2eModule} from './mdc-button/mdc-button-e2e-module'; import {MdcCardE2eModule} from './mdc-card/mdc-card-e2e-module'; import {MdcCheckboxE2eModule} from './mdc-checkbox/mdc-checkbox-e2e-module'; import {MdcChipsE2eModule} from './mdc-chips/mdc-chips-e2e-module'; +import {MdcDialogE2eModule} from './mdc-dialog/mdc-dialog-e2e-module'; import {MdcMenuE2eModule} from './mdc-menu/mdc-menu-e2e-module'; import {MdcRadioE2eModule} from './mdc-radio/mdc-radio-e2e-module'; import {MdcSlideToggleE2eModule} from './mdc-slide-toggle/mdc-slide-toggle-e2e-module'; @@ -65,6 +66,7 @@ import {MdcProgressBarE2eModule} from './mdc-progress-bar/mdc-progress-bar-e2e-m MdcCardE2eModule, MdcCheckboxE2eModule, MdcChipsE2eModule, + MdcDialogE2eModule, MdcMenuE2eModule, MdcRadioE2eModule, MdcSliderE2eModule, diff --git a/src/e2e-app/mdc-dialog/mdc-dialog-e2e-module.ts b/src/e2e-app/mdc-dialog/mdc-dialog-e2e-module.ts new file mode 100644 index 000000000000..5fc5ae7edcf9 --- /dev/null +++ b/src/e2e-app/mdc-dialog/mdc-dialog-e2e-module.ts @@ -0,0 +1,18 @@ +/** + * @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.io/license + */ + +import {NgModule} from '@angular/core'; +import {MatDialogModule} from '@angular/material-experimental/mdc-dialog'; +import {MdcDialogE2E, TestDialog} from './mdc-dialog-e2e'; + +@NgModule({ + imports: [MatDialogModule], + declarations: [MdcDialogE2E, TestDialog], +}) +export class MdcDialogE2eModule { +} diff --git a/src/e2e-app/mdc-dialog/mdc-dialog-e2e.html b/src/e2e-app/mdc-dialog/mdc-dialog-e2e.html new file mode 100644 index 000000000000..95b8dc4e8dc6 --- /dev/null +++ b/src/e2e-app/mdc-dialog/mdc-dialog-e2e.html @@ -0,0 +1,5 @@ + + + + +
my template dialog
diff --git a/src/e2e-app/mdc-dialog/mdc-dialog-e2e.ts b/src/e2e-app/mdc-dialog/mdc-dialog-e2e.ts new file mode 100644 index 000000000000..0308ab6f56bf --- /dev/null +++ b/src/e2e-app/mdc-dialog/mdc-dialog-e2e.ts @@ -0,0 +1,44 @@ +import {Component, ViewChild, TemplateRef} from '@angular/core'; +import {MatDialog, MatDialogRef, MatDialogConfig} from '@angular/material-experimental/mdc-dialog'; + +@Component({ + selector: 'mdc-dialog-e2e', + templateUrl: 'mdc-dialog-e2e.html' +}) +export class MdcDialogE2E { + dialogRef: MatDialogRef | null; + + @ViewChild(TemplateRef) templateRef: TemplateRef; + + constructor (private _dialog: MatDialog) { } + + private _openDialog(config?: MatDialogConfig) { + this.dialogRef = this._dialog.open(TestDialog, config); + this.dialogRef.afterClosed().subscribe(() => this.dialogRef = null); + } + + openDefault() { + this._openDialog(); + } + + openDisabled() { + this._openDialog({ + disableClose: true + }); + } + + openTemplate() { + this.dialogRef = this._dialog.open(this.templateRef); + } +} + +@Component({ + selector: 'dialog-e2e-test', + template: ` +

Lorem ipsum dolor sit amet, consectetur adipisicing elit.

+ + ` +}) +export class TestDialog { + constructor(public dialogRef: MatDialogRef) { } +} diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index ee0a74eea386..d823e887eaac 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -9,6 +9,8 @@ entryPoints = [ "mdc-chips", "mdc-chips/testing", "mdc-core", + "mdc-dialog", + "mdc-dialog/testing", "mdc-form-field", "mdc-form-field/testing", "mdc-input", diff --git a/src/material-experimental/mdc-dialog/BUILD.bazel b/src/material-experimental/mdc-dialog/BUILD.bazel index 309b6993515e..6f26cb79c408 100644 --- a/src/material-experimental/mdc-dialog/BUILD.bazel +++ b/src/material-experimental/mdc-dialog/BUILD.bazel @@ -1,5 +1,3 @@ -package(default_visibility = ["//visibility:public"]) - load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") load( "//tools:defaults.bzl", @@ -11,6 +9,8 @@ load( "sass_library", ) +package(default_visibility = ["//visibility:public"]) + ng_module( name = "mdc-dialog", srcs = glob( @@ -20,7 +20,10 @@ ng_module( assets = [":dialog_scss"] + glob(["**/*.html"]), module_name = "@angular/material-experimental/mdc-dialog", deps = [ + "//src/cdk/overlay", + "//src/cdk/portal", "//src/material/core", + "//src/material/dialog", "@npm//@material/dialog", ], ) @@ -40,6 +43,7 @@ sass_binary( "external/npm/node_modules", ], deps = [ + ":mdc_dialog_scss_lib", "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", ], @@ -57,7 +61,16 @@ ng_test_library( ), deps = [ ":mdc-dialog", + "//src/cdk/a11y", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/overlay", + "//src/cdk/scrolling", + "//src/cdk/testing/private", + "@npm//@angular/common", "@npm//@angular/platform-browser", + "@npm//@material/dialog", + "@npm//rxjs", ], ) diff --git a/src/material-experimental/mdc-dialog/_dialog-legacy-padding.scss b/src/material-experimental/mdc-dialog/_dialog-legacy-padding.scss new file mode 100644 index 000000000000..d852ebe10a9c --- /dev/null +++ b/src/material-experimental/mdc-dialog/_dialog-legacy-padding.scss @@ -0,0 +1,33 @@ +@import '@material/dialog/mixins.import'; + +// Legacy padding for the dialog. Copied from the non-MDC dialog styles. +$mat-mdc-dialog-legacy-padding: 24px !default; + +/// Mixin that applies creates styles for MDC-based dialog's to have legacy outer +/// padding. By default, the dialog does not have any padding. The individual directives +/// such as `matDialogContent`, `matDialogActions` or `matDialogTitle` set the padding. +@mixin mat-mdc-dialog-legacy-padding() { + // Sets the outer padding for the projected dialog user-content. + .mat-mdc-dialog-surface { + padding: $mat-mdc-dialog-legacy-padding; + } + + // Updates the MDC title, content and action elements to account for the legacy outer + // padding. The elements will be shifted so that they behave as if there is no margin. + // This allows us to still rely on MDC's dialog spacing while maintaining a default outer + // padding for backwards compatibility. + .mat-mdc-dialog-container { + .mat-mdc-dialog-title { + margin-top: -$mat-mdc-dialog-legacy-padding; + } + + .mat-mdc-dialog-actions { + margin-bottom: -$mat-mdc-dialog-legacy-padding; + } + + .mat-mdc-dialog-title, .mat-mdc-dialog-content, .mat-mdc-dialog-actions { + margin-left: -$mat-mdc-dialog-legacy-padding; + margin-right: -$mat-mdc-dialog-legacy-padding; + } + } +} diff --git a/src/material-experimental/mdc-dialog/_dialog-theme.scss b/src/material-experimental/mdc-dialog/_dialog-theme.scss index 4aa83224770b..4059a3b33e5c 100644 --- a/src/material-experimental/mdc-dialog/_dialog-theme.scss +++ b/src/material-experimental/mdc-dialog/_dialog-theme.scss @@ -1,11 +1,39 @@ @import '../mdc-helpers/mdc-helpers'; @import '@material/dialog/mixins.import'; -@mixin mat-mdc-dialog-theme($theme) { - @include mat-using-mdc-theme($theme) { +@mixin mat-mdc-dialog-color($config-or-theme) { + $config: mat-get-color-config($config-or-theme); + @include mat-using-mdc-theme($config) { + @include mdc-dialog-core-styles($query: $mat-theme-styles-query); + } } -@mixin mat-mdc-dialog-typography($config) { +@mixin mat-mdc-dialog-typography($config-or-theme) { + $config: mat-get-typography-config($config-or-theme); @include mat-using-mdc-typography($config) { + @include mdc-dialog-core-styles($query: $mat-typography-styles-query); + } +} + +@mixin mat-mdc-dialog-density($config-or-theme) { + $density-scale: mat-get-density-config($config-or-theme); +} + +@mixin mat-mdc-dialog-theme($theme-or-color-config) { + $theme: _mat-legacy-get-theme($theme-or-color-config); + @include _mat-check-duplicate-theme-styles($theme, 'mat-mdc-dialog') { + $color: mat-get-color-config($theme); + $density: mat-get-density-config($theme); + $typography: mat-get-typography-config($theme); + + @if $color != null { + @include mat-mdc-dialog-color($color); + } + @if $density != null { + @include mat-mdc-dialog-density($density); + } + @if $typography != null { + @include mat-mdc-dialog-typography($typography); + } } } diff --git a/src/material-experimental/mdc-dialog/_mdc-dialog-structure-overrides.scss b/src/material-experimental/mdc-dialog/_mdc-dialog-structure-overrides.scss new file mode 100644 index 000000000000..3323fe7c6b2c --- /dev/null +++ b/src/material-experimental/mdc-dialog/_mdc-dialog-structure-overrides.scss @@ -0,0 +1,56 @@ +// Mixin that can be included to override the default MDC dialog styles to fit +// our needs. See individual comments for context on why certain styles need to be modified. +@mixin _mdc-dialog-structure-overrides() { + // MDC dialog sets max-height and max-width on the `mdc-dialog__surface` element. This + // element is the parent of the portal outlet. This means that the actual user-content + // is scrollable, but as per Material Design specification, only the dialog content + // (indicated by `matDialogContent`) should be scrollable while title and actions are fixed. + // This is not an issue for MDC because they make the assumption that the content, title and + // action elements are direct children of the surface element. We work around this by setting + // an explicit max-height for the content element, so that the content is scrollable. This + // matches the behavior from the non-MDC dialog and is backwards compatible. + .mat-mdc-dialog-content { + max-height: $mat-dialog-content-max-height; + } + + .mat-mdc-dialog-container { + // MDC by default expands the dialog using a fixed position to the viewport dimensions. + // MDC does this because the dialog element contains the backdrop/scrim too. This is not + // the case for our implementation of the dialog that uses the CDK overlay backdrop. We + // update the dialog to a `static` position and reset the height/width so that the dialog + // scales based on the content, respecting the boundaries of the CDK overlay container. + // This is necessary to support for custom position strategies in the overlay. + position: static; + // The MDC dialog is designed to always stay in the DOM. Hence MDC sets `display: none` + // when the dialog is closed. Since we only attach the dialog to the DOM when the dialog + // should open, and we want to be able to focus the dialog immediately when we attach it, + // we override the default `display` so that the dialog is focusable. + display: block; + + // MDC sets `min-height`, `max-height`, `min-width` and `max-height` We can't rely on + // this for the dialog content scrolling (as explained above), and since we allow for + // custom positioning through overlay configuration, we remove the rectangle + // restrictions and scale based on the CDK overlay container. + &, .mdc-dialog__container, .mdc-dialog__surface { + max-height: inherit; + min-height: inherit; + min-width: inherit; + max-width: inherit; + } + + // MDC by default sets the `surface` to `display: flex`. MDC does this in order to + // be able to make the content scrollable while locking the title and actions. This + // does not work in our dialog anyway because due to the content projection, arbitrary + // components can be immediate children of the surface and make this a noop. If only + // templates or DOM elements are projected, the flex display could cause unexpected + // alignment issues as explained in our coding standards (see `CODING_STANDARDS.md`). + // Additionally, the surface by default should expand based on the parent overlay + // boundaries (so that boundaries for the overlay config are respected). The surface + // by default would only expand based on its content. + .mdc-dialog__surface { + display: block; + width: 100%; + height: 100%; + } + } +} diff --git a/src/material-experimental/mdc-dialog/dialog-container.html b/src/material-experimental/mdc-dialog/dialog-container.html new file mode 100644 index 000000000000..bcb86f4c42d1 --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog-container.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/src/material-experimental/mdc-dialog/dialog-container.ts b/src/material-experimental/mdc-dialog/dialog-container.ts new file mode 100644 index 000000000000..18400b1748c6 --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog-container.ts @@ -0,0 +1,185 @@ +/** + * @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.io/license + */ + +import {FocusMonitor, FocusTrapFactory} from '@angular/cdk/a11y'; +import {DOCUMENT} from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + NgZone, + OnDestroy, + Optional, + ViewEncapsulation +} from '@angular/core'; +import {MatDialogConfig, _MatDialogContainerBase} from '@angular/material/dialog'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {cssClasses, numbers} from '@material/dialog'; + +/** + * Internal component that wraps user-provided dialog content in a MDC dialog. + * @docs-private + */ +@Component({ + selector: 'mat-mdc-dialog-container', + templateUrl: 'dialog-container.html', + styleUrls: ['dialog.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'class': 'mat-mdc-dialog-container mdc-dialog', + 'tabindex': '-1', + 'aria-modal': 'true', + '[id]': '_id', + '[attr.role]': '_config.role', + '[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy', + '[attr.aria-label]': '_config.ariaLabel', + '[attr.aria-describedby]': '_config.ariaDescribedBy || null', + '[class._mat-animation-noopable]': '!_animationsEnabled', + }, +}) +export class MatDialogContainer extends _MatDialogContainerBase implements OnDestroy { + /** Whether animations are enabled. */ + _animationsEnabled: boolean = this._animationMode !== 'NoopAnimations'; + + /** Host element of the dialog container component. */ + private _hostElement: HTMLElement = this._elementRef.nativeElement; + /** Duration of the dialog open animation. */ + private _openAnimationDuration = + this._animationsEnabled ? numbers.DIALOG_ANIMATION_OPEN_TIME_MS : 0; + /** Duration of the dialog close animation. */ + private _closeAnimationDuration = + this._animationsEnabled ? numbers.DIALOG_ANIMATION_CLOSE_TIME_MS : 0; + /** Current timer for dialog animations. */ + private _animationTimer: number|null = null; + + constructor( + elementRef: ElementRef, + focusTrapFactory: FocusTrapFactory, + changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(DOCUMENT) document: any, + config: MatDialogConfig, + private _ngZone: NgZone, + @Optional() @Inject(ANIMATION_MODULE_TYPE) private _animationMode?: string, + focusMonitor?: FocusMonitor) { + super(elementRef, focusTrapFactory, changeDetectorRef, document, config, focusMonitor); + } + + _initializeWithAttachedContent() { + // 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._initializeWithAttachedContent(); + // 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. + // 3. Foundation uses unnecessary timers for animations to work around limitations + // in React's `setState` mechanism. + // https://github.com/material-components/material-components-web/pull/3682. + this._startOpenAnimation(); + } + + ngOnDestroy() { + if (this._animationTimer !== null) { + clearTimeout(this._animationTimer); + } + } + + /** Starts the dialog open animation if enabled. */ + private _startOpenAnimation() { + this._animationStateChanged.emit({state: 'opening', totalTime: this._openAnimationDuration}); + + if (this._animationsEnabled) { + // 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._hostElement.classList.add(cssClasses.OPENING); + this._hostElement.classList.add(cssClasses.OPEN); + this._waitForAnimationToComplete(this._openAnimationDuration, this._finishDialogOpen); + } else { + this._hostElement.classList.add(cssClasses.OPEN); + // 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._closeAnimationDuration}); + this._hostElement.classList.remove(cssClasses.OPEN); + + if (this._animationsEnabled) { + this._hostElement.classList.add(cssClasses.CLOSING); + this._waitForAnimationToComplete(this._closeAnimationDuration, 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()); + } + } + + /** + * Completes the dialog open by clearing potential animation classes, trapping + * focus and emitting an opened event. + */ + private _finishDialogOpen = + () => { + this._clearAnimationClasses(); + this._trapFocus(); + this._animationStateChanged.emit({state: 'opened', totalTime: this._openAnimationDuration}); + } + + /** + * Completes the dialog close by clearing potential animation classes, restoring + * focus and emitting a closed event. + */ + private _finishDialogClose = + () => { + this._clearAnimationClasses(); + this._restoreFocus(); + this._animationStateChanged.emit( + {state: 'closed', totalTime: this._closeAnimationDuration}); + } + + /** Clears all dialog animation classes. */ + private _clearAnimationClasses() { + this._hostElement.classList.remove(cssClasses.OPENING); + this._hostElement.classList.remove(cssClasses.CLOSING); + } + + private _waitForAnimationToComplete(duration: number, callback: () => void) { + if (this._animationTimer !== null) { + clearTimeout(this._animationTimer); + } + this._ngZone.runOutsideAngular(() => this._animationTimer = setTimeout(callback, duration)); + } +} diff --git a/src/material-experimental/mdc-dialog/dialog-content-directives.ts b/src/material-experimental/mdc-dialog/dialog-content-directives.ts new file mode 100644 index 000000000000..a6d74417c8bf --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog-content-directives.ts @@ -0,0 +1,161 @@ +/** + * @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.io/license + */ + +import { + Directive, + ElementRef, + Input, + OnChanges, + OnInit, + Optional, + SimpleChanges, +} from '@angular/core'; +import {_closeDialogVia} from '@angular/material/dialog'; + +import {MatDialog} from './dialog'; +import {MatDialogRef} from './dialog-ref'; + +/** Counter used to generate unique IDs for dialog elements. */ +let dialogElementUid = 0; + +/** + * Button that will close the current dialog. + */ +@Directive({ + selector: '[mat-dialog-close], [matDialogClose]', + exportAs: 'matDialogClose', + host: { + '(click)': '_onButtonClick($event)', + '[attr.aria-label]': 'ariaLabel || null', + '[attr.type]': 'type', + } +}) +export class MatDialogClose implements OnInit, OnChanges { + /** Screenreader label for the button. */ + @Input('aria-label') ariaLabel: string; + + /** Default to "button" to prevents accidental form submits. */ + @Input() type: 'submit'|'button'|'reset' = 'button'; + + /** Dialog close input. */ + @Input('mat-dialog-close') dialogResult: any; + + @Input('matDialogClose') _matDialogClose: any; + + constructor( + // The dialog title directive is always used in combination with a `MatDialogRef`. + // tslint:disable-next-line: lightweight-tokens + @Optional() public dialogRef: MatDialogRef, + private _elementRef: ElementRef, + private _dialog: MatDialog) {} + + ngOnInit() { + if (!this.dialogRef) { + // When this directive is included in a dialog via TemplateRef (rather than being + // in a Component), the DialogRef isn't available via injection because embedded + // views cannot be given a custom injector. Instead, we look up the DialogRef by + // ID. This must occur in `onInit`, as the ID binding for the dialog container won't + // be resolved at constructor time. + this.dialogRef = getClosestDialog(this._elementRef, this._dialog.openDialogs)!; + } + } + + ngOnChanges(changes: SimpleChanges) { + const proxiedChange = changes['_matDialogClose'] || changes['_matDialogCloseResult']; + + if (proxiedChange) { + this.dialogResult = proxiedChange.currentValue; + } + } + + _onButtonClick(event: MouseEvent) { + // Determinate the focus origin using the click event, because using the FocusMonitor will + // result in incorrect origins. Most of the time, close buttons will be auto focused in the + // dialog, and therefore clicking the button won't result in a focus change. This means that + // the FocusMonitor won't detect any origin change, and will always output `program`. + _closeDialogVia(this.dialogRef, + event.screenX === 0 && event.screenY === 0 ? 'keyboard' : 'mouse', this.dialogResult); + } +} + +/** + * Title of a dialog element. Stays fixed to the top of the dialog when scrolling. + */ +@Directive({ + selector: '[mat-dialog-title], [matDialogTitle]', + exportAs: 'matDialogTitle', + host: { + 'class': 'mat-mdc-dialog-title mdc-dialog__title', + '[id]': 'id', + }, +}) +export class MatDialogTitle implements OnInit { + @Input() id: string = `mat-mdc-dialog-title-${dialogElementUid++}`; + + constructor( + // The dialog title directive is always used in combination with a `MatDialogRef`. + // tslint:disable-next-line: lightweight-tokens + @Optional() private _dialogRef: MatDialogRef, + private _elementRef: ElementRef, + private _dialog: MatDialog) {} + + ngOnInit() { + if (!this._dialogRef) { + this._dialogRef = getClosestDialog(this._elementRef, this._dialog.openDialogs)!; + } + + if (this._dialogRef) { + Promise.resolve().then(() => { + const container = this._dialogRef._containerInstance; + + if (container && !container._ariaLabelledBy) { + container._ariaLabelledBy = this.id; + } + }); + } + } +} + + +/** + * Scrollable content container of a dialog. + */ +@Directive({ + selector: `[mat-dialog-content], mat-dialog-content, [matDialogContent]`, + host: {'class': 'mat-mdc-dialog-content mdc-dialog__content'} +}) +export class MatDialogContent { +} + + +/** + * Container for the bottom action buttons in a dialog. + * Stays fixed to the bottom when scrolling. + */ +@Directive({ + selector: `[mat-dialog-actions], mat-dialog-actions, [matDialogActions]`, + host: {'class': 'mat-mdc-dialog-actions mdc-dialog__actions'} +}) +export class MatDialogActions { +} + + +/** + * Finds the closest MatDialogRef to an element by looking at the DOM. + * @param element Element relative to which to look for a dialog. + * @param openDialogs References to the currently-open dialogs. + */ +function getClosestDialog(element: ElementRef, openDialogs: MatDialogRef[]) { + let parent: HTMLElement|null = element.nativeElement.parentElement; + + while (parent && !parent.classList.contains('mat-mdc-dialog-container')) { + parent = parent.parentElement; + } + + return parent ? openDialogs.find(dialog => dialog.id === parent!.id) : null; +} diff --git a/src/material-experimental/mdc-dialog/dialog-ref.ts b/src/material-experimental/mdc-dialog/dialog-ref.ts new file mode 100644 index 000000000000..237ec50abe08 --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog-ref.ts @@ -0,0 +1,26 @@ +/** + * @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.io/license + */ + +import {OverlayRef} from '@angular/cdk/overlay'; +import {MatDialogRef as NonMdcDialogRef} from '@angular/material/dialog'; +import {MatDialogContainer} from './dialog-container'; + +// Counter for unique dialog ids. +let uniqueId = 0; + +/** + * Reference to a dialog opened via the MatDialog service. + */ +export class MatDialogRef extends NonMdcDialogRef { + constructor( + overlayRef: OverlayRef, + containerInstance: MatDialogContainer, + id: string = `mat-mdc-dialog-${uniqueId++}`) { + super(overlayRef, containerInstance, id); + } +} diff --git a/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts b/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts index e69de29bb2d1..b469a1f0b6d5 100644 --- a/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts +++ b/src/material-experimental/mdc-dialog/dialog.e2e.spec.ts @@ -0,0 +1,105 @@ +import { + clickElementAtPoint, + expectFocusOn, + expectToExist, + pressKeys, + waitForElement, +} from '@angular/cdk/testing/private/e2e'; +import {browser, by, element, Key} from 'protractor'; + +describe('MDC-based dialog', () => { + beforeEach(async () => await browser.get('/mdc-dialog')); + + it('should open a dialog', async () => { + await element(by.id('default')).click(); + await expectToExist('mat-mdc-dialog-container'); + }); + + it('should open a template dialog', async () => { + await expectToExist('.my-template-dialog', false); + await element(by.id('template')).click(); + await expectToExist('.my-template-dialog'); + }); + + it('should close by clicking on the backdrop', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await clickOnBackdrop(); + await expectToExist('mat-mdc-dialog-container', false); + }); + + it('should close by pressing escape', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await pressKeys(Key.ESCAPE); + await expectToExist('mat-mdc-dialog-container', false); + }); + + it('should close by pressing escape when the first tabbable element has lost focus', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await clickElementAtPoint('mat-mdc-dialog-container', {x: 0, y: 0}); + await pressKeys(Key.ESCAPE); + await expectToExist('mat-mdc-dialog-container', false); + }); + + it('should close by clicking on the "close" button', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await element(by.id('close')).click(); + await expectToExist('mat-mdc-dialog-container', false); + }); + + it('should focus the first focusable element', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await expectFocusOn('mat-mdc-dialog-container input'); + }); + + it('should restore focus to the element that opened the dialog', async () => { + const openButton = element(by.id('default')); + + await openButton.click(); + + await waitForDialog(); + await clickOnBackdrop(); + await expectFocusOn(openButton); + }); + + it('should prevent tabbing out of the dialog', async () => { + await element(by.id('default')).click(); + + await waitForDialog(); + await pressKeys(Key.TAB, Key.TAB, Key.TAB); + await expectFocusOn('#close'); + }); + + it('should be able to prevent closing by clicking on the backdrop', async () => { + await element(by.id('disabled')).click(); + + await waitForDialog(); + await clickOnBackdrop(); + await expectToExist('mat-mdc-dialog-container'); + }); + + it('should be able to prevent closing by pressing escape', async () => { + await element(by.id('disabled')).click(); + + await waitForDialog(); + await pressKeys(Key.ESCAPE); + await expectToExist('mat-mdc-dialog-container'); + }); + + async function waitForDialog() { + await waitForElement('mat-mdc-dialog-container'); + } + + async function clickOnBackdrop() { + await clickElementAtPoint('.cdk-overlay-backdrop', {x: 0, y: 0}); + } +}); diff --git a/src/material-experimental/mdc-dialog/dialog.html b/src/material-experimental/mdc-dialog/dialog.html deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/material-experimental/mdc-dialog/dialog.scss b/src/material-experimental/mdc-dialog/dialog.scss index e69de29bb2d1..d7dd0b559bab 100644 --- a/src/material-experimental/mdc-dialog/dialog.scss +++ b/src/material-experimental/mdc-dialog/dialog.scss @@ -0,0 +1,60 @@ +@import '@material/dialog/mixins.import'; +@import '@material/dialog/variables.import'; +@import '../mdc-helpers/mdc-helpers'; +@import './mdc-dialog-structure-overrides'; + +// Dialog content max height. This has been copied from the standard dialog +// and is needed to make the dialog content scrollable. +$mat-dialog-content-max-height: 65vh !default; +// Dialog button horizontal margin. This has been extracted from MDC as they +// don't expose this value as variable. +$mat-dialog-button-horizontal-margin: 8px !default; + +@include mdc-dialog-core-styles($query: $mat-base-styles-query); +@include _mdc-dialog-structure-overrides(); + +// The dialog container is focusable. We remove the default outline shown in browsers. +.mat-mdc-dialog-container { + outline: 0; +} + +// MDC sets the display behavior for title and actions, but not for content. Since we support +// using the `mdc-dialog__content` as custom element, we need to set the element to `block`. +.mat-mdc-dialog-content { + display: block; +} + +.mat-mdc-dialog-actions { + // For backwards compatibility, actions align at start by default. MDC usually + // aligns actions at the end of the container. + justify-content: start; + + &[align='end'] { + justify-content: flex-end; + } + + &[align='center'] { + justify-content: center; + } + + // MDC applies horizontal margin to buttons that have an explicit `mdc-dialog__button` + // class applied. We can't set this class for projected buttons that consumers of the + // dialog create. To workaround this, we select all Material Design buttons we know and + // add the necessary spacing similar to how MDC applies spacing. + .mat-button-base + .mat-button-base, + .mat-mdc-button-base + .mat-mdc-button-base { + margin-left: $mat-dialog-button-horizontal-margin; + + [dir='rtl'] & { + margin-left: 0; + margin-right: $mat-dialog-button-horizontal-margin; + } + } +} + +// Angular Material supports disabling all animations when NoopAnimationsModule is imported. +// TODO(devversion): Look into using MDC's Sass queries to separate the animation styles and +// conditionally add them. Consider the size cost and churn when deciding whether to switch. +.mat-mdc-dialog-container._mat-animation-noopable .mdc-dialog__container { + transition: none; +} diff --git a/src/material-experimental/mdc-dialog/dialog.spec.ts b/src/material-experimental/mdc-dialog/dialog.spec.ts index 43745b8d8e5a..e2cd4668680c 100644 --- a/src/material-experimental/mdc-dialog/dialog.spec.ts +++ b/src/material-experimental/mdc-dialog/dialog.spec.ts @@ -1 +1,1866 @@ -describe('MDC-based MatDialog', () => {}); +import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {A, ESCAPE} from '@angular/cdk/keycodes'; +import {Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay'; +import {ScrollDispatcher} from '@angular/cdk/scrolling'; +import { + createKeyboardEvent, + dispatchEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, + patchElementFocus +} from '@angular/cdk/testing/private'; +import {Location} from '@angular/common'; +import {SpyLocation} from '@angular/common/testing'; +import { + ChangeDetectionStrategy, + Component, + ComponentFactoryResolver, + Directive, + Inject, + Injector, + NgModule, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + flush, + flushMicrotasks, + inject, + TestBed, + tick, +} from '@angular/core/testing'; +import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {numbers} from '@material/dialog'; +import {Subject} from 'rxjs'; +import { + MAT_DIALOG_DATA, + MAT_DIALOG_DEFAULT_OPTIONS, + MatDialog, + MatDialogModule, + MatDialogRef, + MatDialogState +} from './index'; + +describe('MDC-based MatDialog', () => { + let dialog: MatDialog; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let scrolledSubject = new Subject(); + let focusMonitor: FocusMonitor; + + let testViewContainerRef: ViewContainerRef; + let viewContainerFixture: ComponentFixture; + let mockLocation: SpyLocation; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatDialogModule, DialogTestModule], + providers: [ + {provide: Location, useClass: SpyLocation}, + { + provide: ScrollDispatcher, + useFactory: () => ({scrolled: () => scrolledSubject.asObservable()}) + }, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject( + [MatDialog, Location, OverlayContainer, FocusMonitor], + (d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => { + dialog = d; + mockLocation = l as SpyLocation; + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + focusMonitor = fm; + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + beforeEach(() => { + viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer); + + viewContainerFixture.detectChanges(); + testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; + }); + + it('should open a dialog with a component', () => { + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Pizza'); + expect(dialogRef.componentInstance instanceof PizzaMsg).toBe(true); + expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + + viewContainerFixture.detectChanges(); + let dialogContainerElement = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + }); + + it('should open a dialog with a template', () => { + const templateRefFixture = TestBed.createComponent(ComponentWithTemplateRef); + templateRefFixture.componentInstance.localValue = 'Bees'; + templateRefFixture.detectChanges(); + + const data = {value: 'Knees'}; + + let dialogRef = dialog.open(templateRefFixture.componentInstance.templateRef, {data}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Cheese Bees Knees'); + expect(templateRefFixture.componentInstance.dialogRef).toBe(dialogRef); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + + dialogRef.close(); + }); + + it('should emit when dialog opening animation is complete', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const spy = jasmine.createSpy('afterOpen spy'); + + dialogRef.afterOpened().subscribe(spy); + + viewContainerFixture.detectChanges(); + + // callback should not be called before animation is complete + expect(spy).not.toHaveBeenCalled(); + + flushMicrotasks(); + expect(spy).toHaveBeenCalled(); + })); + + it('should use injector from viewContainerRef for DialogInjector', () => { + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + let dialogInjector = dialogRef.componentInstance.dialogInjector; + + expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + expect(dialogInjector.get(DirectiveWithViewContainer)) + .toBeTruthy('Expected the dialog component to be created with the injector from ' + + 'the viewContainerRef.'); + }); + + it('should open a dialog with a component and no ViewContainerRef', () => { + let dialogRef = dialog.open(PizzaMsg); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Pizza'); + expect(dialogRef.componentInstance instanceof PizzaMsg).toBe(true); + expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + + viewContainerFixture.detectChanges(); + let dialogContainerElement = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + }); + + it('should apply the configured role to the dialog element', () => { + dialog.open(PizzaMsg, {role: 'alertdialog'}); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog'); + }); + + it('should apply the specified `aria-describedby`', () => { + dialog.open(PizzaMsg, {ariaDescribedBy: 'description-element'}); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(dialogContainerElement.getAttribute('aria-describedby')).toBe('description-element'); + }); + + it('should close a dialog and get back a result', fakeAsync(() => { + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let afterCloseCallback = jasmine.createSpy('afterClose callback'); + + dialogRef.afterClosed().subscribe(afterCloseCallback); + dialogRef.close('Charmander'); + viewContainerFixture.detectChanges(); + flush(); + + expect(afterCloseCallback).toHaveBeenCalledWith('Charmander'); + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeNull(); + })); + + it('should dispose of dialog if view container is destroyed while animating', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + viewContainerFixture.destroy(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeNull(); + })); + + it('should dispatch the beforeClosed and afterClosed events when the ' + + 'overlay is detached externally', + fakeAsync(inject([Overlay], (overlay: Overlay) => { + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + scrollStrategy: overlay.scrollStrategies.close() + }); + const beforeClosedCallback = jasmine.createSpy('beforeClosed callback'); + const afterCloseCallback = jasmine.createSpy('afterClosed callback'); + + dialogRef.beforeClosed().subscribe(beforeClosedCallback); + dialogRef.afterClosed().subscribe(afterCloseCallback); + + scrolledSubject.next(); + viewContainerFixture.detectChanges(); + flush(); + + expect(beforeClosedCallback).toHaveBeenCalledTimes(1); + expect(afterCloseCallback).toHaveBeenCalledTimes(1); + }))); + + it('should close a dialog and get back a result before it is closed', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + flush(); + viewContainerFixture.detectChanges(); + + // beforeClose should emit before dialog container is destroyed + const beforeCloseHandler = jasmine.createSpy('beforeClose callback').and.callFake(() => { + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')) + .not.toBeNull('dialog container exists when beforeClose is called'); + }); + + dialogRef.beforeClosed().subscribe(beforeCloseHandler); + dialogRef.close('Bulbasaur'); + viewContainerFixture.detectChanges(); + flush(); + + expect(beforeCloseHandler).toHaveBeenCalledWith('Bulbasaur'); + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeNull(); + })); + + it('should close a dialog via the escape key', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + const event = dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeNull(); + expect(event.defaultPrevented).toBe(true); + })); + + it('should not close a dialog via the escape key with a modifier', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + const event = createKeyboardEvent('keydown', ESCAPE, undefined, {alt: true}); + dispatchEvent(document.body, event); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeTruthy(); + expect(event.defaultPrevented).toBe(false); + })); + + it('should close from a ViewContainerRef with OnPush change detection', fakeAsync(() => { + const onPushFixture = TestBed.createComponent(ComponentWithOnPushViewContainer); + + onPushFixture.detectChanges(); + + const dialogRef = dialog.open( + PizzaMsg, {viewContainerRef: onPushFixture.componentInstance.viewContainerRef}); + + flushMicrotasks(); + onPushFixture.detectChanges(); + flushMicrotasks(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length) + .toBe(1, 'Expected one open dialog.'); + + dialogRef.close(); + flushMicrotasks(); + onPushFixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length) + .toBe(0, 'Expected no open dialogs.'); + })); + + it('should close when clicking on the overlay backdrop', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeFalsy(); + })); + + it('should emit the backdropClick stream when clicking on the overlay backdrop', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + const spy = jasmine.createSpy('backdropClick spy'); + dialogRef.backdropClick().subscribe(spy); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + expect(spy).toHaveBeenCalledTimes(1); + + viewContainerFixture.detectChanges(); + flush(); + + // Additional clicks after the dialog has closed should not be emitted + backdrop.click(); + expect(spy).toHaveBeenCalledTimes(1); + })); + + it('should emit the keyboardEvent stream when key events target the overlay', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + const spy = jasmine.createSpy('keyboardEvent spy'); + dialogRef.keydownEvents().subscribe(spy); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let container = + overlayContainerElement.querySelector('mat-mdc-dialog-container') as HTMLElement; + dispatchKeyboardEvent(document.body, 'keydown', A); + dispatchKeyboardEvent(backdrop, 'keydown', A); + dispatchKeyboardEvent(container, 'keydown', A); + + expect(spy).toHaveBeenCalledTimes(3); + })); + + it('should notify the observers if a dialog has been opened', () => { + dialog.afterOpened.subscribe(ref => { + expect(dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef})).toBe(ref); + }); + }); + + it('should notify the observers if all open dialogs have finished closing', fakeAsync(() => { + const ref1 = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const ref2 = dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + const spy = jasmine.createSpy('afterAllClosed spy'); + + dialog.afterAllClosed.subscribe(spy); + + ref1.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(spy).not.toHaveBeenCalled(); + + ref2.close(); + viewContainerFixture.detectChanges(); + flush(); + expect(spy).toHaveBeenCalled(); + })); + + it('should emit the afterAllClosed stream on subscribe if there are no open dialogs', () => { + const spy = jasmine.createSpy('afterAllClosed spy'); + + dialog.afterAllClosed.subscribe(spy); + + expect(spy).toHaveBeenCalled(); + }); + + it('should override the width of the overlay pane', () => { + dialog.open(PizzaMsg, {width: '500px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.width).toBe('500px'); + }); + + it('should override the height of the overlay pane', () => { + dialog.open(PizzaMsg, {height: '100px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.height).toBe('100px'); + }); + + it('should override the min-width of the overlay pane', () => { + dialog.open(PizzaMsg, {minWidth: '500px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.minWidth).toBe('500px'); + }); + + it('should override the max-width of the overlay pane', fakeAsync(() => { + let dialogRef = dialog.open(PizzaMsg); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.maxWidth) + .toBe('80vw', 'Expected dialog to set a default max-width on overlay pane'); + + dialogRef.close(); + + tick(500); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + dialogRef = dialog.open(PizzaMsg, {maxWidth: '100px'}); + + viewContainerFixture.detectChanges(); + + overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.maxWidth).toBe('100px'); + })); + + it('should override the min-height of the overlay pane', () => { + dialog.open(PizzaMsg, {minHeight: '300px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.minHeight).toBe('300px'); + }); + + it('should override the max-height of the overlay pane', () => { + dialog.open(PizzaMsg, {maxHeight: '100px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.maxHeight).toBe('100px'); + }); + + it('should override the top offset of the overlay pane', () => { + dialog.open(PizzaMsg, {position: {top: '100px'}}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginTop).toBe('100px'); + }); + + it('should override the bottom offset of the overlay pane', () => { + dialog.open(PizzaMsg, {position: {bottom: '200px'}}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginBottom).toBe('200px'); + }); + + it('should override the left offset of the overlay pane', () => { + dialog.open(PizzaMsg, {position: {left: '250px'}}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginLeft).toBe('250px'); + }); + + it('should override the right offset of the overlay pane', () => { + dialog.open(PizzaMsg, {position: {right: '125px'}}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginRight).toBe('125px'); + }); + + it('should allow for the position to be updated', () => { + let dialogRef = dialog.open(PizzaMsg, {position: {left: '250px'}}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginLeft).toBe('250px'); + + dialogRef.updatePosition({left: '500px'}); + + expect(overlayPane.style.marginLeft).toBe('500px'); + }); + + it('should allow for the dimensions to be updated', () => { + let dialogRef = dialog.open(PizzaMsg, {width: '100px'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.width).toBe('100px'); + + dialogRef.updateSize('200px'); + + expect(overlayPane.style.width).toBe('200px'); + }); + + it('should reset the overlay dimensions to their initial size', () => { + let dialogRef = dialog.open(PizzaMsg); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.width).toBeFalsy(); + expect(overlayPane.style.height).toBeFalsy(); + + dialogRef.updateSize('200px', '200px'); + + expect(overlayPane.style.width).toBe('200px'); + expect(overlayPane.style.height).toBe('200px'); + + dialogRef.updateSize(); + + expect(overlayPane.style.width).toBeFalsy(); + expect(overlayPane.style.height).toBeFalsy(); + }); + + it('should allow setting the layout direction', () => { + dialog.open(PizzaMsg, {direction: 'rtl'}); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-global-overlay-wrapper')!; + + expect(overlayPane.getAttribute('dir')).toBe('rtl'); + }); + + it('should inject the correct layout direction in the component instance', () => { + const dialogRef = dialog.open(PizzaMsg, {direction: 'rtl'}); + + viewContainerFixture.detectChanges(); + + expect(dialogRef.componentInstance.directionality.value).toBe('rtl'); + }); + + it('should fall back to injecting the global direction if none is passed by the config', () => { + const dialogRef = dialog.open(PizzaMsg, {}); + + viewContainerFixture.detectChanges(); + + expect(dialogRef.componentInstance.directionality.value).toBe('ltr'); + }); + + it('should use the passed in ViewContainerRef from the config', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + // One view ref is for the container and one more for the component with the content. + expect(testViewContainerRef.length).toBe(2); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(testViewContainerRef.length).toBe(0); + })); + + it('should close all of the dialogs', fakeAsync(() => { + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(3); + + dialog.closeAll(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(0); + })); + + it('should close all dialogs when the user goes forwards/backwards in history', fakeAsync(() => { + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(2); + + mockLocation.simulateUrlPop(''); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(0); + })); + + it('should close all open dialogs when the location hash changes', fakeAsync(() => { + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(2); + + mockLocation.simulateHashChange(''); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(0); + })); + + it('should close all of the dialogs when the injectable is destroyed', fakeAsync(() => { + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + dialog.open(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(3); + + dialog.ngOnDestroy(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(0); + })); + + it('should complete open and close streams when the injectable is destroyed', fakeAsync(() => { + const afterOpenedSpy = jasmine.createSpy('after opened spy'); + const afterAllClosedSpy = jasmine.createSpy('after all closed spy'); + const afterOpenedSubscription = dialog.afterOpened.subscribe({complete: afterOpenedSpy}); + const afterAllClosedSubscription = + dialog.afterAllClosed.subscribe({complete: afterAllClosedSpy}); + + dialog.ngOnDestroy(); + + expect(afterOpenedSpy).toHaveBeenCalled(); + expect(afterAllClosedSpy).toHaveBeenCalled(); + + afterOpenedSubscription.unsubscribe(); + afterAllClosedSubscription.unsubscribe(); + })); + + it('should allow the consumer to disable closing a dialog on navigation', fakeAsync(() => { + dialog.open(PizzaMsg); + dialog.open(PizzaMsg, {closeOnNavigation: false}); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(2); + + mockLocation.simulateUrlPop(''); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('mat-mdc-dialog-container').length).toBe(1); + })); + + it('should have the componentInstance available in the afterClosed callback', fakeAsync(() => { + let dialogRef = dialog.open(PizzaMsg); + let spy = jasmine.createSpy('afterClosed spy'); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + dialogRef.afterClosed().subscribe(() => { + spy(); + expect(dialogRef.componentInstance) + .toBeTruthy('Expected component instance to be defined.'); + }); + + dialogRef.close(); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + // Ensure that the callback actually fires. + expect(spy).toHaveBeenCalled(); + })); + + it('should be able to attach a custom scroll strategy', fakeAsync(() => { + const scrollStrategy: ScrollStrategy = { + attach: () => {}, + enable: jasmine.createSpy('scroll strategy enable spy'), + disable: () => {} + }; + + dialog.open(PizzaMsg, {scrollStrategy}); + expect(scrollStrategy.enable).toHaveBeenCalled(); + })); + + it('should be able to pass in an alternate ComponentFactoryResolver', + inject([ComponentFactoryResolver], (resolver: ComponentFactoryResolver) => { + spyOn(resolver, 'resolveComponentFactory').and.callThrough(); + + dialog.open( + PizzaMsg, {viewContainerRef: testViewContainerRef, componentFactoryResolver: resolver}); + viewContainerFixture.detectChanges(); + + expect(resolver.resolveComponentFactory).toHaveBeenCalled(); + })); + + describe('passing in data', () => { + it('should be able to pass in data', () => { + let config = {data: {stringParam: 'hello', dateParam: new Date()}}; + + let instance = dialog.open(DialogWithInjectedData, config).componentInstance; + + expect(instance.data.stringParam).toBe(config.data.stringParam); + expect(instance.data.dateParam).toBe(config.data.dateParam); + }); + + it('should default to null if no data is passed', () => { + expect(() => { + let dialogRef = dialog.open(DialogWithInjectedData); + expect(dialogRef.componentInstance.data).toBeNull(); + }).not.toThrow(); + }); + }); + + it('should not keep a reference to the component after the dialog is closed', fakeAsync(() => { + let dialogRef = dialog.open(PizzaMsg); + + expect(dialogRef.componentInstance).toBeTruthy(); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(dialogRef.componentInstance).toBeFalsy('Expected reference to have been cleared.'); + })); + + it('should assign a unique id to each dialog', () => { + const one = dialog.open(PizzaMsg); + const two = dialog.open(PizzaMsg); + + expect(one.id).toBeTruthy(); + expect(two.id).toBeTruthy(); + expect(one.id).not.toBe(two.id); + }); + + it('should allow for the id to be overwritten', () => { + const dialogRef = dialog.open(PizzaMsg, {id: 'pizza'}); + expect(dialogRef.id).toBe('pizza'); + }); + + it('should throw when trying to open a dialog with the same id as another dialog', () => { + dialog.open(PizzaMsg, {id: 'pizza'}); + expect(() => dialog.open(PizzaMsg, {id: 'pizza'})).toThrowError(/must be unique/g); + }); + + it('should be able to find a dialog by id', () => { + const dialogRef = dialog.open(PizzaMsg, {id: 'pizza'}); + expect(dialog.getDialogById('pizza')).toBe(dialogRef); + }); + + it('should toggle `aria-hidden` on the overlay container siblings', fakeAsync(() => { + const sibling = document.createElement('div'); + overlayContainerElement.parentNode!.appendChild(sibling); + + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden'); + expect(overlayContainerElement.hasAttribute('aria-hidden')) + .toBe(false, 'Expected overlay container not to be hidden.'); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.hasAttribute('aria-hidden')) + .toBe(false, 'Expected sibling to no longer be hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + + it('should restore `aria-hidden` to the overlay container siblings on close', fakeAsync(() => { + const sibling = document.createElement('div'); + + sibling.setAttribute('aria-hidden', 'true'); + overlayContainerElement.parentNode!.appendChild(sibling); + + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden.'); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')) + .toBe('true', 'Expected sibling to remain hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + + it('should not set `aria-hidden` on `aria-live` elements', fakeAsync(() => { + const sibling = document.createElement('div'); + + sibling.setAttribute('aria-live', 'polite'); + overlayContainerElement.parentNode!.appendChild(sibling); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.hasAttribute('aria-hidden')) + .toBe(false, 'Expected live element not to be hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + + it('should add and remove classes while open', () => { + let dialogRef = + dialog.open(PizzaMsg, {disableClose: true, viewContainerRef: testViewContainerRef}); + + const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + expect(pane.classList) + .not.toContain('custom-class-one', 'Expected class to be initially missing'); + + dialogRef.addPanelClass('custom-class-one'); + expect(pane.classList).toContain('custom-class-one', 'Expected class to be added'); + + dialogRef.removePanelClass('custom-class-one'); + expect(pane.classList).not.toContain('custom-class-one', 'Expected class to be removed'); + }); + + describe('disableClose option', () => { + it('should prevent closing via clicks on the backdrop', fakeAsync(() => { + dialog.open(PizzaMsg, {disableClose: true, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeTruthy(); + })); + + it('should prevent closing via the escape key', fakeAsync(() => { + dialog.open(PizzaMsg, {disableClose: true, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeTruthy(); + })); + + it('should allow for the disableClose option to be updated while open', fakeAsync(() => { + let dialogRef = + dialog.open(PizzaMsg, {disableClose: true, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeTruthy(); + + dialogRef.disableClose = false; + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeFalsy(); + })); + + it('should recapture focus when clicking on the backdrop', fakeAsync(() => { + dialog.open(PizzaMsg, {disableClose: true, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let input = overlayContainerElement.querySelector('input') as HTMLInputElement; + + expect(document.activeElement).toBe(input, 'Expected input to be focused on open'); + + input.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement).toBe(input, 'Expected input to stay focused after click'); + })); + + it('should recapture focus to the container when clicking on the backdrop with ' + + 'autoFocus disabled', + fakeAsync(() => { + dialog.open( + PizzaMsg, + {disableClose: true, viewContainerRef: testViewContainerRef, autoFocus: false}); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let container = + overlayContainerElement.querySelector('.mat-mdc-dialog-container') as HTMLInputElement; + + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + + container.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement) + .toBe(container, 'Expected container to stay focused after click'); + })); + }); + + describe('hasBackdrop option', () => { + it('should have a backdrop', () => { + dialog.open(PizzaMsg, {hasBackdrop: true, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy(); + }); + + it('should not have a backdrop', () => { + dialog.open(PizzaMsg, {hasBackdrop: false, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy(); + }); + }); + + describe('panelClass option', () => { + it('should have custom panel class', () => { + dialog.open( + PizzaMsg, {panelClass: 'custom-panel-class', viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.custom-panel-class')).toBeTruthy(); + }); + }); + + describe('backdropClass option', () => { + it('should have default backdrop class', () => { + dialog.open(PizzaMsg, {backdropClass: '', viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-dark-backdrop')).toBeTruthy(); + }); + + it('should have custom backdrop class', () => { + dialog.open( + PizzaMsg, + {backdropClass: 'custom-backdrop-class', viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.custom-backdrop-class')).toBeTruthy(); + }); + }); + + describe('focus management', () => { + // When testing focus, all of the elements must be in the DOM. + beforeEach(() => document.body.appendChild(overlayContainerElement)); + afterEach(() => document.body.removeChild(overlayContainerElement)); + + it('should focus the first tabbable element of the dialog on open', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.tagName) + .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); + })); + + it('should allow disabling focus of the first tabbable element', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef, autoFocus: false}); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.tagName).not.toBe('INPUT'); + })); + + it('should attach the focus trap even if automatic focus is disabled', fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef, autoFocus: false}); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(overlayContainerElement.querySelectorAll('.cdk-focus-trap-anchor').length) + .toBeGreaterThan(0); + })); + + it('should re-focus trigger element when dialog closes', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + let button = document.createElement('button'); + button.id = 'dialog-trigger'; + document.body.appendChild(button); + button.focus(); + + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id) + .not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.'); + + dialogRef.close(); + expect(document.activeElement!.id) + .not.toBe( + 'dialog-trigger', + 'Expcted the focus not to have changed before the animation finishes.'); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(document.activeElement!.id) + .toBe( + 'dialog-trigger', + 'Expected that the trigger was refocused after the dialog is closed.'); + + document.body.removeChild(button); + })); + + it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an + // element. + patchElementFocus(button); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('keyboard', 'Expected the trigger button to be focused via keyboard'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an + // element. + patchElementFocus(button); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); + + const backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('mouse', 'Expected the trigger button to be focused via mouse'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus via keyboard if the close button has been triggered through keyboard', + fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an + // element. + patchElementFocus(button); + + dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); + + const closeButton = + overlayContainerElement.querySelector('button[mat-dialog-close]') as HTMLElement; + + // Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click` + // event with a MouseEvent, which has coordinates that are out of the element boundaries. + dispatchMouseEvent(closeButton, 'click', 0, 0); + + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('keyboard', 'Expected the trigger button to be focused via keyboard'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an + // element. + patchElementFocus(button); + + dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); + + const closeButton = + overlayContainerElement.querySelector('button[mat-dialog-close]') as HTMLElement; + + // The dialog close button detects the focus origin by inspecting the click event. If + // coordinates of the click are not present, it assumes that the click has been triggered + // by keyboard. + dispatchMouseEvent(closeButton, 'click', 10, 10); + + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('mouse', 'Expected the trigger button to be focused via mouse'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + let button = document.createElement('button'); + let input = document.createElement('input'); + + button.id = 'dialog-trigger'; + input.id = 'input-to-be-focused'; + + document.body.appendChild(button); + document.body.appendChild(input); + button.focus(); + + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + + dialogRef.afterClosed().subscribe(() => input.focus()); + dialogRef.close(); + + tick(500); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id) + .toBe( + 'input-to-be-focused', + 'Expected that the trigger was refocused after the dialog is closed.'); + + document.body.removeChild(button); + document.body.removeChild(input); + flush(); + })); + + it('should move focus to the container if there are no focusable elements in the dialog', + fakeAsync(() => { + dialog.open(DialogWithoutFocusableElements); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.tagName) + .toBe('MAT-MDC-DIALOG-CONTAINER', 'Expected dialog container to be focused.'); + })); + + it('should be able to disable focus restoration', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + button.id = 'dialog-trigger'; + document.body.appendChild(button); + button.focus(); + + const dialogRef = + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef, restoreFocus: false}); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id) + .not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.'); + + dialogRef.close(); + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(document.activeElement!.id) + .not.toBe('dialog-trigger', 'Expected focus not to have been restored.'); + + document.body.removeChild(button); + })); + + it('should not move focus if it was moved outside the dialog while animating', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + const otherButton = document.createElement('button'); + const body = document.body; + button.id = 'dialog-trigger'; + otherButton.id = 'other-button'; + body.appendChild(button); + body.appendChild(otherButton); + button.focus(); + + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id) + .not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.'); + + // Start the closing sequence and move focus out of dialog. + dialogRef.close(); + otherButton.focus(); + + expect(document.activeElement!.id) + .toBe('other-button', 'Expected focus to be on the alternate button.'); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id) + .toBe('other-button', 'Expected focus to stay on the alternate button.'); + + body.removeChild(button); + body.removeChild(otherButton); + })); + }); + + describe('dialog content elements', () => { + let dialogRef: MatDialogRef; + + describe('inside component dialog', () => { + beforeEach(fakeAsync(() => { + dialogRef = dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + })); + + runContentElementTests(); + }); + + describe('inside template portal', () => { + beforeEach(fakeAsync(() => { + const fixture = TestBed.createComponent(ComponentWithContentElementTemplateRef); + fixture.detectChanges(); + + dialogRef = dialog.open( + fixture.componentInstance.templateRef, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + flush(); + })); + + runContentElementTests(); + }); + + function runContentElementTests() { + it('should close the dialog when clicking on the close button', fakeAsync(() => { + expect(overlayContainerElement.querySelectorAll('.mat-mdc-dialog-container').length) + .toBe(1); + + (overlayContainerElement.querySelector('button[mat-dialog-close]') as HTMLElement) + .click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('.mat-mdc-dialog-container').length) + .toBe(0); + })); + + it('should not close if [mat-dialog-close] is applied on a non-button node', () => { + expect(overlayContainerElement.querySelectorAll('.mat-mdc-dialog-container').length) + .toBe(1); + + (overlayContainerElement.querySelector('div[mat-dialog-close]') as HTMLElement).click(); + + expect(overlayContainerElement.querySelectorAll('.mat-mdc-dialog-container').length) + .toBe(1); + }); + + it('should allow for a user-specified aria-label on the close button', fakeAsync(() => { + let button = overlayContainerElement.querySelector('.close-with-aria-label')!; + expect(button.getAttribute('aria-label')).toBe('Best close button ever'); + })); + + it('should set the "type" attribute of the close button if not set manually', () => { + let button = overlayContainerElement.querySelector('button[mat-dialog-close]')!; + + expect(button.getAttribute('type')).toBe('button'); + }); + + it('should not override type attribute of the close button if set manually', () => { + let button = overlayContainerElement.querySelector('button.with-submit')!; + + expect(button.getAttribute('type')).toBe('submit'); + }); + + it('should return the [mat-dialog-close] result when clicking the close button', + fakeAsync(() => { + let afterCloseCallback = jasmine.createSpy('afterClose callback'); + dialogRef.afterClosed().subscribe(afterCloseCallback); + + (overlayContainerElement.querySelector('button.close-with-true') as HTMLElement).click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(afterCloseCallback).toHaveBeenCalledWith(true); + })); + + it('should set the aria-labelledby attribute to the id of the title', fakeAsync(() => { + let title = overlayContainerElement.querySelector('[mat-dialog-title]')!; + let container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + + flush(); + viewContainerFixture.detectChanges(); + + expect(title.id).toBeTruthy('Expected title element to have an id.'); + expect(container.getAttribute('aria-labelledby')) + .toBe(title.id, 'Expected the aria-labelledby to match the title id.'); + })); + } + }); + + describe('aria-labelledby', () => { + it('should be able to set a custom aria-labelledby', () => { + dialog.open( + PizzaMsg, {ariaLabelledBy: 'Labelled By', viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(container.getAttribute('aria-labelledby')).toBe('Labelled By'); + }); + + it('should not set the aria-labelledby automatically if it has an aria-label ' + + 'and an aria-labelledby', + fakeAsync(() => { + dialog.open(ContentElementDialog, { + ariaLabel: 'Hello there', + ariaLabelledBy: 'Labelled By', + viewContainerRef: testViewContainerRef + }); + viewContainerFixture.detectChanges(); + tick(); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(container.hasAttribute('aria-labelledby')).toBe(false); + })); + + it('should set the aria-labelledby attribute to the config provided aria-labelledby ' + + 'instead of the mat-dialog-title id', + fakeAsync(() => { + dialog.open( + ContentElementDialog, + {ariaLabelledBy: 'Labelled By', viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + let title = overlayContainerElement.querySelector('[mat-dialog-title]')!; + let container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + flush(); + viewContainerFixture.detectChanges(); + + expect(title.id).toBeTruthy('Expected title element to have an id.'); + expect(container.getAttribute('aria-labelledby')).toBe('Labelled By'); + })); + }); + + describe('aria-label', () => { + it('should be able to set a custom aria-label', () => { + dialog.open(PizzaMsg, {ariaLabel: 'Hello there', viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(container.getAttribute('aria-label')).toBe('Hello there'); + }); + + it('should not set the aria-labelledby automatically if it has an aria-label', fakeAsync(() => { + dialog.open( + ContentElementDialog, + {ariaLabel: 'Hello there', viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + tick(); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('mat-mdc-dialog-container')!; + expect(container.hasAttribute('aria-labelledby')).toBe(false); + })); + }); +}); + +describe('MDC-based MatDialog with a parent MatDialog', () => { + let parentDialog: MatDialog; + let childDialog: MatDialog; + let overlayContainerElement: HTMLElement; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatDialogModule, DialogTestModule], + declarations: [ComponentThatProvidesMatDialog], + providers: [ + { + provide: OverlayContainer, + useFactory: () => { + overlayContainerElement = document.createElement('div'); + return {getContainerElement: () => overlayContainerElement}; + } + }, + {provide: Location, useClass: SpyLocation} + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([MatDialog], (d: MatDialog) => { + parentDialog = d; + + fixture = TestBed.createComponent(ComponentThatProvidesMatDialog); + childDialog = fixture.componentInstance.dialog; + fixture.detectChanges(); + })); + + afterEach(() => { + overlayContainerElement.innerHTML = ''; + }); + + it('should close dialogs opened by a parent when calling closeAll on a child MatDialog', + fakeAsync(() => { + parentDialog.open(PizzaMsg); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); + + childDialog.closeAll(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent!.trim()) + .toBe('', 'Expected closeAll on child MatDialog to close dialog opened by parent'); + })); + + it('should close dialogs opened by a child when calling closeAll on a parent MatDialog', + fakeAsync(() => { + childDialog.open(PizzaMsg); + fixture.detectChanges(); + + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); + + parentDialog.closeAll(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent!.trim()) + .toBe('', 'Expected closeAll on parent MatDialog to close dialog opened by child'); + })); + + it('should close the top dialog via the escape key', fakeAsync(() => { + childDialog.open(PizzaMsg); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeNull(); + })); + + it('should not close the parent dialogs when a child is destroyed', fakeAsync(() => { + parentDialog.open(PizzaMsg); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); + + childDialog.ngOnDestroy(); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.textContent) + .toContain('Pizza', 'Expected a dialog to be opened'); + })); +}); + +describe('MDC-based MatDialog with default options', () => { + let dialog: MatDialog; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + + let testViewContainerRef: ViewContainerRef; + let viewContainerFixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + const defaultConfig = { + hasBackdrop: false, + disableClose: true, + width: '100px', + height: '100px', + minWidth: '50px', + minHeight: '50px', + maxWidth: '150px', + maxHeight: '150px', + autoFocus: false, + }; + + TestBed.configureTestingModule({ + imports: [MatDialogModule, DialogTestModule], + providers: [ + {provide: MAT_DIALOG_DEFAULT_OPTIONS, useValue: defaultConfig}, + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([MatDialog, OverlayContainer], (d: MatDialog, oc: OverlayContainer) => { + dialog = d; + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + beforeEach(() => { + viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer); + + viewContainerFixture.detectChanges(); + testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; + }); + + it('should use the provided defaults', () => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy(); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeTruthy(); + + expect(document.activeElement!.tagName).not.toBe('INPUT'); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + expect(overlayPane.style.width).toBe('100px'); + expect(overlayPane.style.height).toBe('100px'); + expect(overlayPane.style.minWidth).toBe('50px'); + expect(overlayPane.style.minHeight).toBe('50px'); + expect(overlayPane.style.maxWidth).toBe('150px'); + expect(overlayPane.style.maxHeight).toBe('150px'); + }); + + it('should be overridable by open() options', fakeAsync(() => { + dialog.open( + PizzaMsg, + {hasBackdrop: true, disableClose: false, viewContainerRef: testViewContainerRef}); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy(); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('mat-mdc-dialog-container')).toBeFalsy(); + })); +}); + + +describe('MDC-based MatDialog with animations enabled', () => { + let dialog: MatDialog; + let overlayContainer: OverlayContainer; + + let testViewContainerRef: ViewContainerRef; + let viewContainerFixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatDialogModule, DialogTestModule, BrowserAnimationsModule], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([MatDialog, OverlayContainer], (d: MatDialog, oc: OverlayContainer) => { + dialog = d; + overlayContainer = oc; + + viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer); + viewContainerFixture.detectChanges(); + testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; + })); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should emit when dialog opening animation is complete', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const spy = jasmine.createSpy('afterOpen spy'); + + dialogRef.afterOpened().subscribe(spy); + + viewContainerFixture.detectChanges(); + + // callback should not be called before animation is complete + expect(spy).not.toHaveBeenCalled(); + + tick(numbers.DIALOG_ANIMATION_OPEN_TIME_MS); + expect(spy).toHaveBeenCalled(); + })); + + it('should return the current state of the dialog', fakeAsync(() => { + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + // Duration of the close animation in milliseconds. + const dialogCloseDuration = numbers.DIALOG_ANIMATION_CLOSE_TIME_MS; + + expect(dialogRef.getState()).toBe(MatDialogState.OPEN); + dialogRef.close(); + viewContainerFixture.detectChanges(); + + expect(dialogRef.getState()).toBe(MatDialogState.CLOSING); + + // Ensure that the closing state is still set if half of the animation has + // passed by. The dialog state should be only set to `closed` when the dialog + // finished the close animation. + tick(dialogCloseDuration / 2); + expect(dialogRef.getState()).toBe(MatDialogState.CLOSING); + + // Flush the remaining duration of the closing animation. We flush all other remaining + // tasks (e.g. the fallback close timeout) to avoid fakeAsync pending timer failures. + flush(); + expect(dialogRef.getState()).toBe(MatDialogState.CLOSED); + })); +}); + +@Directive({selector: 'dir-with-view-container'}) +class DirectiveWithViewContainer { + constructor(public viewContainerRef: ViewContainerRef) {} +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: 'hello', +}) +class ComponentWithOnPushViewContainer { + constructor(public viewContainerRef: ViewContainerRef) {} +} + +@Component({ + selector: 'arbitrary-component', + template: ``, +}) +class ComponentWithChildViewContainer { + @ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer; + + get childViewContainer() { + return this.childWithViewContainer.viewContainerRef; + } +} + +@Component({ + selector: 'arbitrary-component-with-template-ref', + template: ` + Cheese {{localValue}} {{data?.value}}{{setDialogRef(dialogRef)}}`, +}) +class ComponentWithTemplateRef { + localValue: string; + dialogRef: MatDialogRef; + + @ViewChild(TemplateRef) templateRef: TemplateRef; + + setDialogRef(dialogRef: MatDialogRef): string { + this.dialogRef = dialogRef; + return ''; + } +} + +/** Simple component for testing ComponentPortal. */ +@Component({template: '

Pizza

'}) +class PizzaMsg { + constructor( + public dialogRef: MatDialogRef, public dialogInjector: Injector, + public directionality: Directionality) {} +} + +@Component({ + template: ` +

This is the title

+ Lorem ipsum dolor sit amet. + + + + +
Should not close
+ +
+ ` +}) +class ContentElementDialog { +} + +@Component({ + template: ` + +

This is the title

+ Lorem ipsum dolor sit amet. + + + + +
Should not close
+ +
+
+ ` +}) +class ComponentWithContentElementTemplateRef { + @ViewChild(TemplateRef) templateRef: TemplateRef; +} + +@Component({template: '', providers: [MatDialog]}) +class ComponentThatProvidesMatDialog { + constructor(public dialog: MatDialog) {} +} + +/** Simple component for testing ComponentPortal. */ +@Component({template: ''}) +class DialogWithInjectedData { + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} +} + +@Component({template: '

Pasta

'}) +class DialogWithoutFocusableElements { +} + +// Create a real (non-test) NgModule as a workaround for +// https://github.com/angular/angular/issues/10760 +const TEST_DIRECTIVES = [ + ComponentWithChildViewContainer, + ComponentWithTemplateRef, + PizzaMsg, + DirectiveWithViewContainer, + ComponentWithOnPushViewContainer, + ContentElementDialog, + DialogWithInjectedData, + DialogWithoutFocusableElements, + ComponentWithContentElementTemplateRef, +]; + +@NgModule({ + imports: [MatDialogModule, NoopAnimationsModule], + exports: TEST_DIRECTIVES, + declarations: TEST_DIRECTIVES, + entryComponents: [ + ComponentWithChildViewContainer, + ComponentWithTemplateRef, + PizzaMsg, + ContentElementDialog, + DialogWithInjectedData, + DialogWithoutFocusableElements, + ], +}) +class DialogTestModule { +} diff --git a/src/material-experimental/mdc-dialog/dialog.ts b/src/material-experimental/mdc-dialog/dialog.ts index b819486a9fe4..d27b61434fea 100644 --- a/src/material-experimental/mdc-dialog/dialog.ts +++ b/src/material-experimental/mdc-dialog/dialog.ts @@ -6,4 +6,55 @@ * found in the LICENSE file at https://angular.io/license */ -export class MatDialog {} +import {Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay'; +import {Location} from '@angular/common'; +import {Inject, Injectable, InjectionToken, Injector, Optional, SkipSelf} from '@angular/core'; +import {_MatDialogBase, MatDialogConfig} from '@angular/material/dialog'; +import {MatDialogContainer} from './dialog-container'; +import {MatDialogRef} from './dialog-ref'; + +/** Injection token that can be used to access the data that was passed in to a dialog. */ +export const MAT_DIALOG_DATA = new InjectionToken('MatMdcDialogData'); + +/** Injection token that can be used to specify default dialog options. */ +export const MAT_DIALOG_DEFAULT_OPTIONS = + new InjectionToken('mat-mdc-dialog-default-options'); + +/** Injection token that determines the scroll handling while the dialog is open. */ +export const MAT_DIALOG_SCROLL_STRATEGY = + new InjectionToken<() => ScrollStrategy>('mat-mdc-dialog-scroll-strategy'); + +/** @docs-private */ +export function MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay): () => + ScrollStrategy { + return () => overlay.scrollStrategies.block(); +} + +/** @docs-private */ +export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = { + provide: MAT_DIALOG_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, +}; + +/** + * Service to open Material Design modal dialogs. + */ +@Injectable() +export class MatDialog extends _MatDialogBase { + constructor( + overlay: Overlay, + injector: Injector, + /** + * @deprecated `_location` parameter to be removed. + * @breaking-change 10.0.0 + */ + @Optional() location: Location, + @Optional() @Inject(MAT_DIALOG_DEFAULT_OPTIONS) defaultOptions: MatDialogConfig, + @Inject(MAT_DIALOG_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() @SkipSelf() parentDialog: MatDialog, overlayContainer: OverlayContainer) { + super( + overlay, injector, defaultOptions, parentDialog, overlayContainer, scrollStrategy, + MatDialogRef, MatDialogContainer, MAT_DIALOG_DATA); + } +} diff --git a/src/material-experimental/mdc-dialog/module.ts b/src/material-experimental/mdc-dialog/module.ts index b205b4948a21..d2a5d9df419d 100644 --- a/src/material-experimental/mdc-dialog/module.ts +++ b/src/material-experimental/mdc-dialog/module.ts @@ -6,14 +6,45 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {PortalModule} from '@angular/cdk/portal'; import {NgModule} from '@angular/core'; import {MatCommonModule} from '@angular/material/core'; +import {MAT_DIALOG_SCROLL_STRATEGY_PROVIDER, MatDialog} from './dialog'; +import {MatDialogContainer} from './dialog-container'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle, +} from './dialog-content-directives'; @NgModule({ - imports: [MatCommonModule, CommonModule], - exports: [], - declarations: [], + imports: [ + OverlayModule, + PortalModule, + MatCommonModule, + ], + exports: [ + MatDialogContainer, + MatDialogClose, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatCommonModule, + ], + declarations: [ + MatDialogContainer, + MatDialogClose, + MatDialogTitle, + MatDialogActions, + MatDialogContent, + ], + providers: [ + MatDialog, + MAT_DIALOG_SCROLL_STRATEGY_PROVIDER, + ], + entryComponents: [MatDialogContainer], }) export class MatDialogModule { } diff --git a/src/material-experimental/mdc-dialog/public-api.ts b/src/material-experimental/mdc-dialog/public-api.ts index 972291486cb6..1a651f191f75 100644 --- a/src/material-experimental/mdc-dialog/public-api.ts +++ b/src/material-experimental/mdc-dialog/public-api.ts @@ -7,4 +7,16 @@ */ export * from './dialog'; +export * from './dialog-ref'; +export * from './dialog-content-directives'; +export * from './dialog-container'; export * from './module'; + +export { + MatDialogState, + MatDialogConfig, + matDialogAnimations, + throwMatDialogContentAlreadyAttachedError, + DialogRole, + DialogPosition, +} from '@angular/material/dialog'; diff --git a/src/material-experimental/mdc-dialog/testing/BUILD.bazel b/src/material-experimental/mdc-dialog/testing/BUILD.bazel new file mode 100644 index 000000000000..ef7eed564ece --- /dev/null +++ b/src/material-experimental/mdc-dialog/testing/BUILD.bazel @@ -0,0 +1,45 @@ +load("//tools:defaults.bzl", "ng_test_library", "ng_web_test_suite", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "testing", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/material-experimental/mdc-dialog/testing", + deps = [ + "//src/cdk/testing", + "//src/material/dialog/testing", + ], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) + +ng_test_library( + name = "unit_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["shared.spec.ts"], + ), + deps = [ + ":testing", + "//src/material-experimental/mdc-dialog", + "//src/material/dialog/testing:harness_tests_lib", + ], +) + +ng_web_test_suite( + name = "unit_tests", + static_files = [ + "@npm//:node_modules/@material/dialog/dist/mdc.dialog.js", + ], + deps = [ + ":unit_tests_lib", + "//src/material-experimental:mdc_require_config.js", + ], +) diff --git a/src/material-experimental/mdc-dialog/testing/dialog-harness.spec.ts b/src/material-experimental/mdc-dialog/testing/dialog-harness.spec.ts new file mode 100644 index 000000000000..d7d6dd60c996 --- /dev/null +++ b/src/material-experimental/mdc-dialog/testing/dialog-harness.spec.ts @@ -0,0 +1,7 @@ +import {MatDialog, MatDialogModule} from '@angular/material-experimental/mdc-dialog'; +import {runHarnessTests} from '@angular/material/dialog/testing/shared.spec'; +import {MatDialogHarness} from './dialog-harness'; + +describe('MDC-based MatDialog', () => { + runHarnessTests(MatDialogModule, MatDialogHarness, MatDialog as any); +}); diff --git a/src/material-experimental/mdc-dialog/testing/dialog-harness.ts b/src/material-experimental/mdc-dialog/testing/dialog-harness.ts new file mode 100644 index 000000000000..f1856ecf54ac --- /dev/null +++ b/src/material-experimental/mdc-dialog/testing/dialog-harness.ts @@ -0,0 +1,29 @@ +/** + * @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.io/license + */ + +import {HarnessPredicate} from '@angular/cdk/testing'; +import { + DialogHarnessFilters, + MatDialogHarness as NonMdcDialogHarness +} from '@angular/material/dialog/testing'; + +/** Harness for interacting with a standard `MatDialog` in tests. */ +export class MatDialogHarness extends NonMdcDialogHarness { + /** The selector for the host element of a `MatDialog` instance. */ + static hostSelector = '.mat-mdc-dialog-container'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a `MatDialogHarness` that meets + * certain criteria. + * @param options Options for filtering which dialog instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: DialogHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatDialogHarness, options); + } +} diff --git a/src/material-experimental/mdc-dialog/testing/index.ts b/src/material-experimental/mdc-dialog/testing/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-dialog/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @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.io/license + */ + +export * from './public-api'; diff --git a/src/material-experimental/mdc-dialog/testing/public-api.ts b/src/material-experimental/mdc-dialog/testing/public-api.ts new file mode 100644 index 000000000000..574bc96799a7 --- /dev/null +++ b/src/material-experimental/mdc-dialog/testing/public-api.ts @@ -0,0 +1,10 @@ +/** + * @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.io/license + */ + +export {DialogHarnessFilters} from '@angular/material/dialog/testing'; +export {MatDialogHarness} from './dialog-harness'; diff --git a/src/material-experimental/mdc-theming/BUILD.bazel b/src/material-experimental/mdc-theming/BUILD.bazel index 1c060e36bbf1..96ebcb652a1f 100644 --- a/src/material-experimental/mdc-theming/BUILD.bazel +++ b/src/material-experimental/mdc-theming/BUILD.bazel @@ -21,6 +21,7 @@ sass_library( "//src/material-experimental/mdc-checkbox:mdc_checkbox_scss_lib", "//src/material-experimental/mdc-chips:mdc_chips_scss_lib", "//src/material-experimental/mdc-core:mdc_core_scss_lib", + "//src/material-experimental/mdc-dialog:mdc_dialog_scss_lib", "//src/material-experimental/mdc-form-field:mdc_form_field_scss_lib", "//src/material-experimental/mdc-input:mdc_input_scss_lib", "//src/material-experimental/mdc-list:mdc_list_scss_lib", diff --git a/src/material-experimental/mdc-theming/_all-theme.scss b/src/material-experimental/mdc-theming/_all-theme.scss index f3132abc5205..28ade0179dc6 100644 --- a/src/material-experimental/mdc-theming/_all-theme.scss +++ b/src/material-experimental/mdc-theming/_all-theme.scss @@ -3,6 +3,7 @@ @import '../mdc-card/card-theme'; @import '../mdc-checkbox/checkbox-theme'; @import '../mdc-chips/chips-theme'; +@import '../mdc-dialog/dialog-theme'; @import '../mdc-list/list-theme'; @import '../mdc-menu/menu-theme'; @import '../mdc-radio/radio-theme'; @@ -19,6 +20,7 @@ @include _mat-check-duplicate-theme-styles($theme-or-color-config, 'angular-material-mdc-theme') { @include mat-mdc-core-theme($theme-or-color-config); @include mat-mdc-button-theme($theme-or-color-config); + @include mat-mdc-dialog-theme($theme-or-color-config); @include mat-mdc-fab-theme($theme-or-color-config); @include mat-mdc-icon-button-theme($theme-or-color-config); @include mat-mdc-card-theme($theme-or-color-config); diff --git a/src/material/dialog/dialog-container.ts b/src/material/dialog/dialog-container.ts index b70f55f92f98..8d02cfe262e6 100644 --- a/src/material/dialog/dialog-container.ts +++ b/src/material/dialog/dialog-container.ts @@ -6,32 +6,38 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationEvent} from '@angular/animations'; +import {FocusMonitor, FocusOrigin, FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import { + BasePortalOutlet, + CdkPortalOutlet, + ComponentPortal, + DomPortal, + TemplatePortal +} from '@angular/cdk/portal'; +import {DOCUMENT} from '@angular/common'; import { + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ComponentRef, + Directive, ElementRef, EmbeddedViewRef, EventEmitter, Inject, Optional, - ChangeDetectorRef, ViewChild, ViewEncapsulation, - ChangeDetectionStrategy, } from '@angular/core'; -import {DOCUMENT} from '@angular/common'; -import {AnimationEvent} from '@angular/animations'; import {matDialogAnimations} from './dialog-animations'; -import { - BasePortalOutlet, - ComponentPortal, - CdkPortalOutlet, - TemplatePortal, - DomPortal -} from '@angular/cdk/portal'; -import {FocusTrap, FocusMonitor, FocusOrigin, FocusTrapFactory} from '@angular/cdk/a11y'; import {MatDialogConfig} from './dialog-config'; +/** Event that captures the state of dialog container animations. */ +interface DialogAnimationEvent { + state: 'opened' | 'opening' | 'closing' | 'closed'; + totalTime: number; +} /** * Throws an exception for the case when a ComponentPortal is @@ -43,35 +49,13 @@ export function throwMatDialogContentAlreadyAttachedError() { } /** - * Internal component that wraps user-provided dialog content. - * Animation is based on https://material.io/guidelines/motion/choreography.html. - * @docs-private + * Base class for the `MatDialogContainer`. The base class does not implement + * animations as these are left to implementers of the dialog container. */ -@Component({ - selector: 'mat-dialog-container', - templateUrl: 'dialog-container.html', - styleUrls: ['dialog.css'], - encapsulation: ViewEncapsulation.None, - // Using OnPush for dialogs caused some G3 sync issues. Disabled until we can track them down. - // tslint:disable-next-line:validate-decorators - changeDetection: ChangeDetectionStrategy.Default, - animations: [matDialogAnimations.dialogContainer], - host: { - 'class': 'mat-dialog-container', - 'tabindex': '-1', - 'aria-modal': 'true', - '[attr.id]': '_id', - '[attr.role]': '_config.role', - '[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy', - '[attr.aria-label]': '_config.ariaLabel', - '[attr.aria-describedby]': '_config.ariaDescribedBy || null', - '[@dialogContainer]': '_state', - '(@dialogContainer.start)': '_onAnimationStart($event)', - '(@dialogContainer.done)': '_onAnimationDone($event)', - }, -}) -export class MatDialogContainer extends BasePortalOutlet { - private _document: Document; +@Directive() +// tslint:disable-next-line:class-name +export abstract class _MatDialogContainerBase extends BasePortalOutlet { + protected _document: Document; /** The portal outlet inside of this container into which the dialog content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; @@ -79,6 +63,9 @@ export class MatDialogContainer extends BasePortalOutlet { /** The class that traps and manages focus within the dialog. */ private _focusTrap: FocusTrap; + /** Emits when an animation state changes. */ + _animationStateChanged = new EventEmitter(); + /** Element that was focused before the dialog was opened. Save this to restore upon close. */ private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null; @@ -89,12 +76,6 @@ export class MatDialogContainer extends BasePortalOutlet { */ _closeInteractionType: FocusOrigin|null = null; - /** State of the dialog animation. */ - _state: 'void' | 'enter' | 'exit' = 'enter'; - - /** Emits when an animation state changes. */ - _animationStateChanged = new EventEmitter(); - /** ID of the element that should be considered as the dialog's label. */ _ariaLabelledBy: string | null; @@ -102,9 +83,9 @@ export class MatDialogContainer extends BasePortalOutlet { _id: string; constructor( - private _elementRef: ElementRef, - private _focusTrapFactory: FocusTrapFactory, - private _changeDetectorRef: ChangeDetectorRef, + protected _elementRef: ElementRef, + protected _focusTrapFactory: FocusTrapFactory, + protected _changeDetectorRef: ChangeDetectorRef, @Optional() @Inject(DOCUMENT) _document: any, /** The dialog configuration. */ public _config: MatDialogConfig, @@ -115,6 +96,20 @@ export class MatDialogContainer extends BasePortalOutlet { this._document = _document; } + /** Starts the dialog exit animation. */ + abstract _startExitAnimation(): void; + + /** Initializes the dialog container with the attached content. */ + _initializeWithAttachedContent() { + this._setupFocusTrap(); + // Save the previously focused element. This element will be re-focused + // when the dialog closes. + this._capturePreviouslyFocusedElement(); + // Move focus onto the dialog immediately in order to prevent the user + // from accidentally opening multiple dialogs at the same time. + this._focusDialogContainer(); + } + /** * Attach a ComponentPortal as content to this dialog container. * @param portal Portal to be attached as the dialog content. @@ -124,7 +119,6 @@ export class MatDialogContainer extends BasePortalOutlet { throwMatDialogContentAlreadyAttachedError(); } - this._setupFocusTrap(); return this._portalOutlet.attachComponentPortal(portal); } @@ -137,7 +131,6 @@ export class MatDialogContainer extends BasePortalOutlet { throwMatDialogContentAlreadyAttachedError(); } - this._setupFocusTrap(); return this._portalOutlet.attachTemplatePortal(portal); } @@ -152,7 +145,6 @@ export class MatDialogContainer extends BasePortalOutlet { throwMatDialogContentAlreadyAttachedError(); } - this._setupFocusTrap(); return this._portalOutlet.attachDomPortal(portal); } @@ -168,7 +160,7 @@ export class MatDialogContainer extends BasePortalOutlet { } /** Moves the focus inside the focus trap. */ - private _trapFocus() { + protected _trapFocus() { // If we were to attempt to focus immediately, then the content of the dialog would not yet be // ready in instances where change detection has to run first. To deal with this, we simply // wait for the microtask queue to be empty. @@ -185,7 +177,7 @@ export class MatDialogContainer extends BasePortalOutlet { } /** Restores focus to the element that was focused before the dialog opened. */ - private _restoreFocus() { + protected _restoreFocus() { const previousElement = this._elementFocusedBeforeDialogWasOpened; // We need the extra check, because IE can set the `activeElement` to null in some cases. @@ -214,25 +206,23 @@ export class MatDialogContainer extends BasePortalOutlet { } } - /** - * Sets up the focus trand and saves a reference to the - * element that was focused before the dialog was opened. - */ + /** Sets up the focus trap. */ private _setupFocusTrap() { - if (!this._focusTrap) { - this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); - } + this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); + } + /** Captures the element that was focused before the dialog was opened. */ + private _capturePreviouslyFocusedElement() { if (this._document) { this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement; + } + } - // Note that there is no focus method when rendering on the server. - if (this._elementRef.nativeElement.focus) { - // Move focus onto the dialog immediately in order to prevent the user from accidentally - // opening multiple dialogs at the same time. Needs to be async, because the element - // may not be focusable immediately. - Promise.resolve().then(() => this._elementRef.nativeElement.focus()); - } + /** Focuses the dialog container. */ + private _focusDialogContainer() { + // Note that there is no focus method when rendering on the server. + if (this._elementRef.nativeElement.focus) { + this._elementRef.nativeElement.focus(); } } @@ -242,21 +232,58 @@ export class MatDialogContainer extends BasePortalOutlet { const activeElement = this._document.activeElement; return element === activeElement || element.contains(activeElement); } +} + +/** + * Internal component that wraps user-provided dialog content. + * Animation is based on https://material.io/guidelines/motion/choreography.html. + * @docs-private + */ +@Component({ + selector: 'mat-dialog-container', + templateUrl: 'dialog-container.html', + styleUrls: ['dialog.css'], + encapsulation: ViewEncapsulation.None, + // Using OnPush for dialogs caused some G3 sync issues. Disabled until we can track them down. + // tslint:disable-next-line:validate-decorators + changeDetection: ChangeDetectionStrategy.Default, + animations: [matDialogAnimations.dialogContainer], + host: { + 'class': 'mat-dialog-container', + 'tabindex': '-1', + 'aria-modal': 'true', + '[id]': '_id', + '[attr.role]': '_config.role', + '[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy', + '[attr.aria-label]': '_config.ariaLabel', + '[attr.aria-describedby]': '_config.ariaDescribedBy || null', + '[@dialogContainer]': '_state', + '(@dialogContainer.start)': '_onAnimationStart($event)', + '(@dialogContainer.done)': '_onAnimationDone($event)', + }, +}) +export class MatDialogContainer extends _MatDialogContainerBase { + /** State of the dialog animation. */ + _state: 'void' | 'enter' | 'exit' = 'enter'; /** Callback, invoked whenever an animation on the host completes. */ - _onAnimationDone(event: AnimationEvent) { - if (event.toState === 'enter') { + _onAnimationDone({toState, totalTime}: AnimationEvent) { + if (toState === 'enter') { this._trapFocus(); - } else if (event.toState === 'exit') { + this._animationStateChanged.next({state: 'opened', totalTime}); + } else if (toState === 'exit') { this._restoreFocus(); + this._animationStateChanged.next({state: 'closed', totalTime}); } - - this._animationStateChanged.emit(event); } /** Callback, invoked when an animation on the host starts. */ - _onAnimationStart(event: AnimationEvent) { - this._animationStateChanged.emit(event); + _onAnimationStart({toState, totalTime}: AnimationEvent) { + if (toState === 'enter') { + this._animationStateChanged.next({state: 'opening', totalTime}); + } else if (toState === 'exit') { + this._animationStateChanged.next({state: 'closing', totalTime}); + } } /** Starts the dialog exit animation. */ diff --git a/src/material/dialog/dialog-content-directives.ts b/src/material/dialog/dialog-content-directives.ts index 45a021a2eeb5..73121c0cc08e 100644 --- a/src/material/dialog/dialog-content-directives.ts +++ b/src/material/dialog/dialog-content-directives.ts @@ -96,11 +96,11 @@ export class MatDialogTitle implements OnInit { @Input() id: string = `mat-dialog-title-${dialogElementUid++}`; constructor( - // The dialog title directive is always used in combination with a `MatDialogRef`. - // tslint:disable-next-line: lightweight-tokens - @Optional() private _dialogRef: MatDialogRef, - private _elementRef: ElementRef, - private _dialog: MatDialog) {} + // The dialog title directive is always used in combination with a `MatDialogRef`. + // tslint:disable-next-line: lightweight-tokens + @Optional() private _dialogRef: MatDialogRef, + private _elementRef: ElementRef, + private _dialog: MatDialog) {} ngOnInit() { if (!this._dialogRef) { diff --git a/src/material/dialog/dialog-ref.ts b/src/material/dialog/dialog-ref.ts index b12368aa98e3..23e1fbb4300c 100644 --- a/src/material/dialog/dialog-ref.ts +++ b/src/material/dialog/dialog-ref.ts @@ -12,7 +12,7 @@ import {GlobalPositionStrategy, OverlayRef} from '@angular/cdk/overlay'; import {Observable, Subject} from 'rxjs'; import {filter, take} from 'rxjs/operators'; import {DialogPosition} from './dialog-config'; -import {MatDialogContainer} from './dialog-container'; +import {_MatDialogContainerBase} from './dialog-container'; // TODO(jelbourn): resizing @@ -53,7 +53,7 @@ export class MatDialogRef { constructor( private _overlayRef: OverlayRef, - public _containerInstance: MatDialogContainer, + public _containerInstance: _MatDialogContainerBase, readonly id: string = `mat-dialog-${uniqueId++}`) { // Pass the id along to the container. @@ -61,7 +61,7 @@ export class MatDialogRef { // Emit when opening animation completes _containerInstance._animationStateChanged.pipe( - filter(event => event.phaseName === 'done' && event.toState === 'enter'), + filter(event => event.state === 'opened'), take(1) ) .subscribe(() => { @@ -71,7 +71,7 @@ export class MatDialogRef { // Dispose overlay when closing animation is complete _containerInstance._animationStateChanged.pipe( - filter(event => event.phaseName === 'done' && event.toState === 'exit'), + filter(event => event.state === 'closed'), take(1) ).subscribe(() => { clearTimeout(this._closeFallbackTimeout); @@ -114,7 +114,7 @@ export class MatDialogRef { // Transition the backdrop in parallel to the dialog. this._containerInstance._animationStateChanged.pipe( - filter(event => event.phaseName === 'start'), + filter(event => event.state === 'closing'), take(1) ) .subscribe(event => { @@ -131,8 +131,8 @@ export class MatDialogRef { event.totalTime + 100); }); - this._containerInstance._startExitAnimation(); this._state = MatDialogState.CLOSING; + this._containerInstance._startExitAnimation(); } /** diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 3872b2408239..b23011d03c3e 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -1166,7 +1166,6 @@ describe('MatDialog', () => { tick(500); viewContainerFixture.detectChanges(); - expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); @@ -1200,6 +1199,7 @@ describe('MatDialog', () => { tick(500); viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); const backdrop = overlayContainerElement .querySelector('.cdk-overlay-backdrop') as HTMLElement; @@ -1235,6 +1235,7 @@ describe('MatDialog', () => { tick(500); viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); const closeButton = overlayContainerElement .querySelector('button[mat-dialog-close]') as HTMLElement; @@ -1271,6 +1272,7 @@ describe('MatDialog', () => { tick(500); viewContainerFixture.detectChanges(); + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); const closeButton = overlayContainerElement .querySelector('button[mat-dialog-close]') as HTMLElement; diff --git a/src/material/dialog/dialog.ts b/src/material/dialog/dialog.ts index 155f4e0b161c..05a089f21cc8 100644 --- a/src/material/dialog/dialog.ts +++ b/src/material/dialog/dialog.ts @@ -17,6 +17,7 @@ import { import {ComponentPortal, ComponentType, TemplatePortal} from '@angular/cdk/portal'; import {Location} from '@angular/common'; import { + Directive, Inject, Injectable, InjectionToken, @@ -24,13 +25,14 @@ import { OnDestroy, Optional, SkipSelf, - TemplateRef, StaticProvider, + TemplateRef, + Type, } from '@angular/core'; import {defer, Observable, of as observableOf, Subject} from 'rxjs'; import {startWith} from 'rxjs/operators'; import {MatDialogConfig} from './dialog-config'; -import {MatDialogContainer} from './dialog-container'; +import {MatDialogContainer, _MatDialogContainerBase} from './dialog-container'; import {MatDialogRef} from './dialog-ref'; @@ -63,12 +65,13 @@ export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = { useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, }; - /** - * Service to open Material Design modal dialogs. + * Base class for dialog services. The base dialog service allows + * for arbitrary dialog refs and dialog container components. */ -@Injectable() -export class MatDialog implements OnDestroy { +@Directive() +// tslint:disable-next-line:class-name +export abstract class _MatDialogBase implements OnDestroy { private _openDialogsAtThisLevel: MatDialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject>(); @@ -102,15 +105,13 @@ export class MatDialog implements OnDestroy { constructor( private _overlay: Overlay, private _injector: Injector, - /** - * @deprecated `_location` parameter to be removed. - * @breaking-change 10.0.0 - */ - @Optional() _location: Location, - @Optional() @Inject(MAT_DIALOG_DEFAULT_OPTIONS) private _defaultOptions: MatDialogConfig, - @Inject(MAT_DIALOG_SCROLL_STRATEGY) scrollStrategy: any, - @Optional() @SkipSelf() private _parentDialog: MatDialog, - private _overlayContainer: OverlayContainer) { + private _defaultOptions: MatDialogConfig|undefined, + private _parentDialog: _MatDialogBase|undefined, + private _overlayContainer: OverlayContainer, + scrollStrategy: any, + private _dialogRefConstructor: Type>, + private _dialogContainerType: Type, + private _dialogDataToken: InjectionToken) { this._scrollStrategy = scrollStrategy; } @@ -146,6 +147,9 @@ export class MatDialog implements OnDestroy { dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef)); this.afterOpened.next(dialogRef); + // Notify the dialog container that the content has been attached. + dialogContainer._initializeWithAttachedContent(); + return dialogRef; } @@ -209,44 +213,43 @@ export class MatDialog implements OnDestroy { } /** - * Attaches an MatDialogContainer to a dialog's already-created overlay. + * Attaches a dialog container to a dialog's already-created overlay. * @param overlay Reference to the dialog's underlying overlay. * @param config The dialog configuration. * @returns A promise resolving to a ComponentRef for the attached container. */ - private _attachDialogContainer(overlay: OverlayRef, config: MatDialogConfig): MatDialogContainer { + private _attachDialogContainer(overlay: OverlayRef, config: MatDialogConfig): C { const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; const injector = Injector.create({ parent: userInjector || this._injector, providers: [{provide: MatDialogConfig, useValue: config}] }); - const containerPortal = new ComponentPortal(MatDialogContainer, + const containerPortal = new ComponentPortal(this._dialogContainerType, config.viewContainerRef, injector, config.componentFactoryResolver); - const containerRef = overlay.attach(containerPortal); + const containerRef = overlay.attach(containerPortal); return containerRef.instance; } /** - * Attaches the user-provided component to the already-created MatDialogContainer. + * Attaches the user-provided component to the already-created dialog container. * @param componentOrTemplateRef The type of component being loaded into the dialog, * or a TemplateRef to instantiate as the content. - * @param dialogContainer Reference to the wrapping MatDialogContainer. + * @param dialogContainer Reference to the wrapping dialog container. * @param overlayRef Reference to the overlay in which the dialog resides. * @param config The dialog configuration. * @returns A promise resolving to the MatDialogRef that should be returned to the user. */ private _attachDialogContent( componentOrTemplateRef: ComponentType | TemplateRef, - dialogContainer: MatDialogContainer, + dialogContainer: C, overlayRef: OverlayRef, config: MatDialogConfig): MatDialogRef { // Create a reference to the dialog we're creating in order to give the user a handle // to modify and close it. - const dialogRef = - new MatDialogRef(overlayRef, dialogContainer, config.id); + const dialogRef = new this._dialogRefConstructor(overlayRef, dialogContainer, config.id); if (componentOrTemplateRef instanceof TemplateRef) { dialogContainer.attachTemplatePortal( @@ -271,24 +274,24 @@ export class MatDialog implements OnDestroy { * of a dialog to close itself and, optionally, to return a value. * @param config Config object that is used to construct the dialog. * @param dialogRef Reference to the dialog. - * @param container Dialog container element that wraps all of the contents. + * @param dialogContainer Dialog container element that wraps all of the contents. * @returns The custom injector that can be used inside the dialog. */ private _createInjector( config: MatDialogConfig, dialogRef: MatDialogRef, - dialogContainer: MatDialogContainer): Injector { + dialogContainer: C): Injector { const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; - // The MatDialogContainer is injected in the portal as the MatDialogContainer and the dialog's - // content are created out of the same ViewContainerRef and as such, are siblings for injector - // purposes. To allow the hierarchy that is expected, the MatDialogContainer is explicitly - // added to the injection tokens. + // The dialog container should be provided as the dialog container and the dialog's + // content are created out of the same `ViewContainerRef` and as such, are siblings + // for injector purposes. To allow the hierarchy that is expected, the dialog + // container is explicitly provided in the injector. const providers: StaticProvider[] = [ - {provide: MatDialogContainer, useValue: dialogContainer}, - {provide: MAT_DIALOG_DATA, useValue: config.data}, - {provide: MatDialogRef, useValue: dialogRef} + {provide: this._dialogContainerType, useValue: dialogContainer}, + {provide: this._dialogDataToken, useValue: config.data}, + {provide: this._dialogRefConstructor, useValue: dialogRef} ]; if (config.direction && @@ -369,6 +372,28 @@ export class MatDialog implements OnDestroy { } +/** + * Service to open Material Design modal dialogs. + */ +@Injectable() +export class MatDialog extends _MatDialogBase { + constructor( + overlay: Overlay, + injector: Injector, + /** + * @deprecated `_location` parameter to be removed. + * @breaking-change 10.0.0 + */ + @Optional() location: Location, + @Optional() @Inject(MAT_DIALOG_DEFAULT_OPTIONS) defaultOptions: MatDialogConfig, + @Inject(MAT_DIALOG_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() @SkipSelf() parentDialog: MatDialog, + overlayContainer: OverlayContainer) { + super(overlay, injector, defaultOptions, parentDialog, overlayContainer, scrollStrategy, + MatDialogRef, MatDialogContainer, MAT_DIALOG_DATA); + } +} + /** * Applies default options to the dialog config. * @param config Config to be modified. diff --git a/src/material/dialog/testing/dialog-harness.spec.ts b/src/material/dialog/testing/dialog-harness.spec.ts index e7b19a8083c3..ac266f417022 100644 --- a/src/material/dialog/testing/dialog-harness.spec.ts +++ b/src/material/dialog/testing/dialog-harness.spec.ts @@ -1,7 +1,7 @@ -import {MatDialogModule} from '@angular/material/dialog'; +import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {runHarnessTests} from '@angular/material/dialog/testing/shared.spec'; import {MatDialogHarness} from './dialog-harness'; describe('Non-MDC-based MatDialogHarness', () => { - runHarnessTests(MatDialogModule, MatDialogHarness); + runHarnessTests(MatDialogModule, MatDialogHarness, MatDialog); }); diff --git a/src/material/dialog/testing/shared.spec.ts b/src/material/dialog/testing/shared.spec.ts index 949c90b71826..284ea1c143d0 100644 --- a/src/material/dialog/testing/shared.spec.ts +++ b/src/material/dialog/testing/shared.spec.ts @@ -9,16 +9,24 @@ import {MatDialogHarness} from './dialog-harness'; /** Shared tests to run on both the original and MDC-based dialog's. */ export function runHarnessTests( - dialogModule: typeof MatDialogModule, dialogHarness: typeof MatDialogHarness) { + dialogModule: typeof MatDialogModule, dialogHarness: typeof MatDialogHarness, + dialogService: typeof MatDialog) { let fixture: ComponentFixture; let loader: HarnessLoader; let overlayContainer: OverlayContainer; beforeEach(async () => { + // If the specified dialog service does not match the default `MatDialog` service + // that is used in the test components below, we provide the `MatDialog` service with + // the existing instance of the specified dialog service. This allows us to run these + // tests for the MDC-based version of the dialog too. + const providers = dialogService !== MatDialog ? + [{provide: MatDialog, useExisting: dialogService}] : undefined; await TestBed .configureTestingModule({ imports: [dialogModule, NoopAnimationsModule], declarations: [DialogHarnessTest], + providers, }) .compileComponents(); @@ -115,21 +123,21 @@ export function runHarnessTests( dialogs = await loader.getAllHarnesses(dialogHarness); expect(dialogs.length).toBe(1); }); -} -@Component({ - template: ` + @Component({ + template: ` Hello from the dialog! ` -}) -class DialogHarnessTest { - @ViewChild(TemplateRef) dialogTmpl: TemplateRef; + }) + class DialogHarnessTest { + @ViewChild(TemplateRef) dialogTmpl: TemplateRef; - constructor(readonly dialog: MatDialog) {} + constructor(readonly dialog: MatDialog) {} - open(config?: MatDialogConfig) { - return this.dialog.open(this.dialogTmpl, config); + open(config?: MatDialogConfig) { + return this.dialog.open(this.dialogTmpl, config); + } } } diff --git a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts index 7c3273c7e847..a7966347c110 100644 --- a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts +++ b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts @@ -12,14 +12,23 @@ import {MatSlideToggleModule} from '@angular/material-experimental/mdc-slide-tog import {MatSliderModule} from '@angular/material-experimental/mdc-slider'; import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; import {MatTableModule} from '@angular/material-experimental/mdc-table'; +import {MatDialog, MatDialogModule} from '@angular/material-experimental/mdc-dialog'; import {MatIconModule} from '@angular/material/icon'; import {MatSnackBarModule, MatSnackBar} from '@angular/material-experimental/mdc-snack-bar'; +@Component({ + template: `` +}) +export class TestEntryComponent {} + @Component({ selector: 'kitchen-sink-mdc', templateUrl: './kitchen-sink-mdc.html', }) export class KitchenSinkMdc { + constructor(dialog: MatDialog) { + dialog.open(TestEntryComponent); + } } @NgModule({ @@ -28,6 +37,7 @@ export class KitchenSinkMdc { MatCardModule, MatCheckboxModule, MatChipsModule, + MatDialogModule, MatFormFieldModule, MatIconModule, MatInputModule, @@ -40,8 +50,9 @@ export class KitchenSinkMdc { MatProgressBarModule, MatSnackBarModule, ], - declarations: [KitchenSinkMdc], - exports: [KitchenSinkMdc], + declarations: [KitchenSinkMdc, TestEntryComponent], + exports: [KitchenSinkMdc, TestEntryComponent], + entryComponents: [TestEntryComponent], providers: [{ // If an error is thrown asynchronously during server-side rendering it'll get logged to stderr, // but it won't cause the build to fail. We still want to catch these errors so we provide an diff --git a/tools/public_api_guard/material/dialog.d.ts b/tools/public_api_guard/material/dialog.d.ts index 72af08231b89..6a5c47114efe 100644 --- a/tools/public_api_guard/material/dialog.d.ts +++ b/tools/public_api_guard/material/dialog.d.ts @@ -1,5 +1,44 @@ export declare function _closeDialogVia(ref: MatDialogRef, interactionType: FocusOrigin, result?: R): void; +export declare abstract class _MatDialogBase implements OnDestroy { + readonly afterAllClosed: Observable; + get afterOpened(): Subject>; + get openDialogs(): MatDialogRef[]; + constructor(_overlay: Overlay, _injector: Injector, _defaultOptions: MatDialogConfig | undefined, _parentDialog: _MatDialogBase | undefined, _overlayContainer: OverlayContainer, scrollStrategy: any, _dialogRefConstructor: Type>, _dialogContainerType: Type, _dialogDataToken: InjectionToken); + _getAfterAllClosed(): Subject; + closeAll(): void; + getDialogById(id: string): MatDialogRef | undefined; + ngOnDestroy(): void; + open(componentOrTemplateRef: ComponentType | TemplateRef, config?: MatDialogConfig): MatDialogRef; + static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatDialogBase, never, never, {}, {}, never>; + static ɵfac: i0.ɵɵFactoryDef<_MatDialogBase, never>; +} + +export declare abstract class _MatDialogContainerBase extends BasePortalOutlet { + _animationStateChanged: EventEmitter; + _ariaLabelledBy: string | null; + protected _changeDetectorRef: ChangeDetectorRef; + _closeInteractionType: FocusOrigin | null; + _config: MatDialogConfig; + protected _document: Document; + protected _elementRef: ElementRef; + protected _focusTrapFactory: FocusTrapFactory; + _id: string; + _portalOutlet: CdkPortalOutlet; + attachDomPortal: (portal: DomPortal) => void; + constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _changeDetectorRef: ChangeDetectorRef, _document: any, + _config: MatDialogConfig, _focusMonitor?: FocusMonitor | undefined); + _initializeWithAttachedContent(): void; + _recaptureFocus(): void; + protected _restoreFocus(): void; + abstract _startExitAnimation(): void; + protected _trapFocus(): void; + attachComponentPortal(portal: ComponentPortal): ComponentRef; + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; + static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatDialogContainerBase, never, never, {}, {}, never>; + static ɵfac: i0.ɵɵFactoryDef<_MatDialogContainerBase, [null, null, null, { optional: true; }, null, null]>; +} + export interface DialogPosition { bottom?: string; left?: string; @@ -25,17 +64,9 @@ export declare const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER: { export declare function MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay): () => ScrollStrategy; -export declare class MatDialog implements OnDestroy { - readonly afterAllClosed: Observable; - get afterOpened(): Subject>; - get openDialogs(): MatDialogRef[]; - constructor(_overlay: Overlay, _injector: Injector, - _location: Location, _defaultOptions: MatDialogConfig, scrollStrategy: any, _parentDialog: MatDialog, _overlayContainer: OverlayContainer); - _getAfterAllClosed(): Subject; - closeAll(): void; - getDialogById(id: string): MatDialogRef | undefined; - ngOnDestroy(): void; - open(componentOrTemplateRef: ComponentType | TemplateRef, config?: MatDialogConfig): MatDialogRef; +export declare class MatDialog extends _MatDialogBase { + constructor(overlay: Overlay, injector: Injector, + location: Location, defaultOptions: MatDialogConfig, scrollStrategy: any, parentDialog: MatDialog, overlayContainer: OverlayContainer); static ɵfac: i0.ɵɵFactoryDef; static ɵprov: i0.ɵɵInjectableDef; } @@ -90,25 +121,13 @@ export declare class MatDialogConfig { width?: string; } -export declare class MatDialogContainer extends BasePortalOutlet { - _animationStateChanged: EventEmitter; - _ariaLabelledBy: string | null; - _closeInteractionType: FocusOrigin | null; - _config: MatDialogConfig; - _id: string; - _portalOutlet: CdkPortalOutlet; +export declare class MatDialogContainer extends _MatDialogContainerBase { _state: 'void' | 'enter' | 'exit'; - attachDomPortal: (portal: DomPortal) => void; - constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _changeDetectorRef: ChangeDetectorRef, _document: any, - _config: MatDialogConfig, _focusMonitor?: FocusMonitor | undefined); - _onAnimationDone(event: AnimationEvent): void; - _onAnimationStart(event: AnimationEvent): void; - _recaptureFocus(): void; + _onAnimationDone({ toState, totalTime }: AnimationEvent): void; + _onAnimationStart({ toState, totalTime }: AnimationEvent): void; _startExitAnimation(): void; - attachComponentPortal(portal: ComponentPortal): ComponentRef; - attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; static ɵcmp: i0.ɵɵComponentDefWithMeta; - static ɵfac: i0.ɵɵFactoryDef; + static ɵfac: i0.ɵɵFactoryDef; } export declare class MatDialogContent { @@ -122,11 +141,11 @@ export declare class MatDialogModule { } export declare class MatDialogRef { - _containerInstance: MatDialogContainer; + _containerInstance: _MatDialogContainerBase; componentInstance: T; disableClose: boolean | undefined; readonly id: string; - constructor(_overlayRef: OverlayRef, _containerInstance: MatDialogContainer, id?: string); + constructor(_overlayRef: OverlayRef, _containerInstance: _MatDialogContainerBase, id?: string); addPanelClass(classes: string | string[]): this; afterClosed(): Observable; afterOpened(): Observable; From 4fa8053c1f053f6f27ae18628b779c7e1fbdb967 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 29 Jul 2020 21:46:54 +0200 Subject: [PATCH 3/3] build: do not leak providers and component styles between tests Angular's `TestBed` by default only removes a test component from the document body and destructs individual component fixtures. It never destroys the test module properly, but always re-creates it. This means that providers are not necessarily cleaned up (could leak in memory) and component styles are not cleaned up / deduped. This results in thousands of style elements in the browsers. Ultimately causing significantly increased memory consumption and CPU blocking. This potentially leads to repeated crashes within browsers (as seen in BrowserStack and Saucelabs in the past). Initial testing of this change unveiled a reduction from 30min tests to 5min in BrowserStack. This is a signficant improvement and we should consider moving this change upstream with: https://github.com/angular/angular/issues/31834. Benefits are: reduced test time; increased test/browser stability; isolated tests without leaking styles and services. --- src/cdk/drag-drop/directives/drag.spec.ts | 56 ++++++++++++++++------- src/cdk/scrolling/scrollable.spec.ts | 6 ++- test/angular-test-init-spec.ts | 22 +++++++-- test/karma-test-shim.js | 8 ++++ 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/src/cdk/drag-drop/directives/drag.spec.ts b/src/cdk/drag-drop/directives/drag.spec.ts index 35a2f1c5625d..da0ba9b9c411 100644 --- a/src/cdk/drag-drop/directives/drag.spec.ts +++ b/src/cdk/drag-drop/directives/drag.spec.ts @@ -2169,7 +2169,7 @@ describe('CdkDrag', () => { fixture.componentInstance.boundarySelector = '.cdk-drop-list'; fixture.detectChanges(); - const container: HTMLElement = fixture.nativeElement.querySelector('.container'); + const container: HTMLElement = fixture.nativeElement.querySelector('.scroll-container'); const item = fixture.componentInstance.dragItems.toArray()[1].element.nativeElement; const list = fixture.componentInstance.dropInstance.element.nativeElement; const cleanup = makeScrollable('vertical', container); @@ -3889,7 +3889,7 @@ describe('CdkDrag', () => { const fixture = createComponent(DraggableInScrollableParentContainer); fixture.detectChanges(); const item = fixture.componentInstance.dragItems.first.element.nativeElement; - const container = fixture.nativeElement.querySelector('.container'); + const container = fixture.nativeElement.querySelector('.scroll-container'); const containerRect = container.getBoundingClientRect(); expect(container.scrollTop).toBe(0); @@ -5519,7 +5519,7 @@ class StandaloneDraggableWithMultipleHandles { const DROP_ZONE_FIXTURE_TEMPLATE = `
; @ViewChild(CdkDropList) dropInstance: CdkDropList; items = [ @@ -5556,6 +5556,15 @@ class DraggableInDropZone { moveItemInArray(this.items, event.previousIndex, event.currentIndex); }); startedSpy = jasmine.createSpy('started spy'); + + constructor(protected _elementRef: ElementRef) {} + + ngAfterViewInit() { + // Firefox preserves the `scrollTop` value from previous similar containers. This + // could throw off test assertions and result in flaky results. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=959812. + this._elementRef.nativeElement.querySelector('.scroll-container').scrollTop = 0; + } } @Component({ @@ -5579,8 +5588,8 @@ class DraggableInOnPushDropZone extends DraggableInDropZone {} `] }) class DraggableInScrollableVerticalDropZone extends DraggableInDropZone { - constructor() { - super(); + constructor(elementRef: ElementRef) { + super(elementRef); for (let i = 0; i < 60; i++) { this.items.push({value: `Extra item ${i}`, height: ITEM_HEIGHT, margin: 0}); @@ -5589,12 +5598,12 @@ class DraggableInScrollableVerticalDropZone extends DraggableInDropZone { } @Component({ - template: '
' + DROP_ZONE_FIXTURE_TEMPLATE + '
', + template: '
' + DROP_ZONE_FIXTURE_TEMPLATE + '
', // Note that it needs a margin to ensure that it's not flush against the viewport // edge which will cause the viewport to scroll, rather than the list. styles: [` - .container { + .scroll-container { max-height: 200px; overflow: auto; margin: 10vw 0 0 10vw; @@ -5602,8 +5611,8 @@ class DraggableInScrollableVerticalDropZone extends DraggableInDropZone { `] }) class DraggableInScrollableParentContainer extends DraggableInDropZone { - constructor() { - super(); + constructor(elementRef: ElementRef) { + super(elementRef); for (let i = 0; i < 60; i++) { this.items.push({value: `Extra item ${i}`, height: ITEM_HEIGHT, margin: 0}); @@ -5617,7 +5626,7 @@ class DraggableInScrollableParentContainer extends DraggableInDropZone { template: `
; @ViewChild(CdkDropList) dropInstance: CdkDropList; items = [ @@ -5688,6 +5697,15 @@ class DraggableInHorizontalDropZone { droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop) => { moveItemInArray(this.items, event.previousIndex, event.currentIndex); }); + + constructor(protected _elementRef: ElementRef) {} + + ngAfterViewInit() { + // Firefox preserves the `scrollLeft` value from previous similar containers. This + // could throw off test assertions and result in flaky results. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=959812. + this._elementRef.nativeElement.querySelector('.scroll-container').scrollLeft = 0; + } } @@ -5706,8 +5724,8 @@ class DraggableInHorizontalDropZone { `] }) class DraggableInScrollableHorizontalDropZone extends DraggableInHorizontalDropZone { - constructor() { - super(); + constructor(elementRef: ElementRef) { + super(elementRef); for (let i = 0; i < 60; i++) { this.items.push({value: `Extra item ${i}`, width: ITEM_WIDTH, margin: 0}); @@ -6142,6 +6160,7 @@ class ConnectedWrappedDropZones { @Component({ template: `
) { - super(); + constructor(elementRef: ElementRef) { + super(elementRef); } ngAfterViewInit() { + super.ngAfterViewInit(); const canvases = this._elementRef.nativeElement.querySelectorAll('canvas'); // Add a circle to all the canvases. @@ -6183,6 +6203,7 @@ class DraggableWithCanvasInDropZone extends DraggableInDropZone implements After @Component({ template: `
{ beforeEach(() => { fixture = TestBed.createComponent(ScrollableViewport); + fixture.detectChanges(); testComponent = fixture.componentInstance; + // Firefox preserves the `scrollTop` value from previous similar containers. This + // could throw off test assertions and result in flaky results. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=959812. + testComponent.scrollContainer.nativeElement.scrollTop = 0; }); describe('in LTR context', () => { beforeEach(() => { - fixture.detectChanges(); maxOffset = testComponent.scrollContainer.nativeElement.scrollHeight - testComponent.scrollContainer.nativeElement.clientHeight; }); diff --git a/test/angular-test-init-spec.ts b/test/angular-test-init-spec.ts index a2bd5a3c2177..df82e1dd7da4 100644 --- a/test/angular-test-init-spec.ts +++ b/test/angular-test-init-spec.ts @@ -1,3 +1,4 @@ +import {NgModuleRef} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import { BrowserDynamicTestingModule, @@ -8,8 +9,8 @@ import { * Common setup / initialization for all unit tests in Angular Material and CDK. */ -const testBed = TestBed.initTestEnvironment( - [BrowserDynamicTestingModule], platformBrowserDynamicTesting()); +const testBed = + TestBed.initTestEnvironment([BrowserDynamicTestingModule], platformBrowserDynamicTesting()); patchTestBedToDestroyFixturesAfterEveryTest(testBed); (window as any).module = {}; @@ -38,9 +39,24 @@ function patchTestBedToDestroyFixturesAfterEveryTest(testBedInstance: TestBed) { // Monkey-patch the resetTestingModule to destroy fixtures outside of a try/catch block. // With https://github.com/angular/angular/commit/2c5a67134198a090a24f6671dcdb7b102fea6eba // errors when destroying components are no longer causing Jasmine to fail. - testBedInstance.resetTestingModule = function(this: {_activeFixtures: ComponentFixture[]}) { + testBedInstance.resetTestingModule = function(this: { + /** List of active fixtures in the current testing module. */ + _activeFixtures: ComponentFixture[], + /** Module Ref used in the Ivy TestBed for creating components. */ + _testModuleRef?: NgModuleRef|null, + /** Module Ref used in the View Engine TestBed for creating components. */ + _moduleRef?: NgModuleRef|null + }) { try { + const moduleRef = this._testModuleRef || this._moduleRef; this._activeFixtures.forEach((fixture: ComponentFixture) => fixture.destroy()); + // Destroy the TestBed `NgModule` reference to clear out shared styles that would + // otherwise remain in DOM and significantly increase memory consumption in browsers. + // This increased consumption then results in noticeable test instability and slow-down. + // See: https://github.com/angular/angular/issues/31834. + if (moduleRef != null) { + moduleRef.destroy(); + } } finally { this._activeFixtures = []; // Regardless of errors or not, run the original reset testing module function. diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index f2406abd5d2b..cda5cf69b5ff 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -79,7 +79,15 @@ function patchTestBedToDestroyFixturesAfterEveryTest(testBed) { // errors when destroying components are no longer causing Jasmine to fail. testBed.resetTestingModule = function() { try { + const moduleRef = this._testModuleRef || this._moduleRef; this._activeFixtures.forEach(function (fixture) { fixture.destroy(); }); + // Destroy the TestBed `NgModule` reference to clear out shared styles that would + // otherwise remain in DOM and significantly increase memory consumption in browsers. + // This increased consumption then results in noticeable test instability and slow-down. + // See: https://github.com/angular/angular/issues/31834. + if (moduleRef != null) { + moduleRef.destroy(); + } } finally { this._activeFixtures = []; // Regardless of errors or not, run the original reset testing module function.