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/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/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 new file mode 100644 index 000000000000..6f26cb79c408 --- /dev/null +++ b/src/material-experimental/mdc-dialog/BUILD.bazel @@ -0,0 +1,102 @@ +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", +) + +package(default_visibility = ["//visibility:public"]) + +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/cdk/overlay", + "//src/cdk/portal", + "//src/material/core", + "//src/material/dialog", + "@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 = [ + ":mdc_dialog_scss_lib", + "//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", + "//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", + ], +) + +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-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 new file mode 100644 index 000000000000..4059a3b33e5c --- /dev/null +++ b/src/material-experimental/mdc-dialog/_dialog-theme.scss @@ -0,0 +1,39 @@ +@import '../mdc-helpers/mdc-helpers'; +@import '@material/dialog/mixins.import'; + +@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-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 new file mode 100644 index 000000000000..b469a1f0b6d5 --- /dev/null +++ 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.scss b/src/material-experimental/mdc-dialog/dialog.scss new file mode 100644 index 000000000000..d7dd0b559bab --- /dev/null +++ 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 new file mode 100644 index 000000000000..e2cd4668680c --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog.spec.ts @@ -0,0 +1,1866 @@ +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 new file mode 100644 index 000000000000..d27b61434fea --- /dev/null +++ b/src/material-experimental/mdc-dialog/dialog.ts @@ -0,0 +1,60 @@ +/** + * @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 {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/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..d2a5d9df419d --- /dev/null +++ b/src/material-experimental/mdc-dialog/module.ts @@ -0,0 +1,50 @@ +/** + * @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 {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: [ + 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 new file mode 100644 index 000000000000..1a651f191f75 --- /dev/null +++ b/src/material-experimental/mdc-dialog/public-api.ts @@ -0,0 +1,22 @@ +/** + * @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 './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/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. 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;