From 4d675d6aacf5b11d03cc22692124eeb28aa48ed1 Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Thu, 14 Dec 2017 15:33:44 -0800 Subject: [PATCH] Create Dialog in CDK. (#8844) --- .github/CODEOWNERS | 1 + .../dialog/dialog-config.ts | 92 ++ .../dialog/dialog-container.html | 1 + .../dialog/dialog-container.scss | 6 + .../dialog/dialog-container.ts | 218 ++++ .../dialog/dialog-injectors.ts | 43 + .../dialog/dialog-module.ts | 48 + .../dialog/dialog-ref.ts | 142 +++ .../dialog/dialog.spec.ts | 1084 +++++++++++++++++ src/material-experimental/dialog/dialog.ts | 309 +++++ src/material-experimental/dialog/index.ts | 9 + .../dialog/public-api.ts | 14 + .../dialog/tsconfig-build.json | 14 + src/material-experimental/tsconfig-build.json | 3 +- src/material-experimental/typings.d.ts | 1 + 15 files changed, 1984 insertions(+), 1 deletion(-) create mode 100644 src/material-experimental/dialog/dialog-config.ts create mode 100644 src/material-experimental/dialog/dialog-container.html create mode 100644 src/material-experimental/dialog/dialog-container.scss create mode 100644 src/material-experimental/dialog/dialog-container.ts create mode 100644 src/material-experimental/dialog/dialog-injectors.ts create mode 100644 src/material-experimental/dialog/dialog-module.ts create mode 100644 src/material-experimental/dialog/dialog-ref.ts create mode 100644 src/material-experimental/dialog/dialog.spec.ts create mode 100644 src/material-experimental/dialog/dialog.ts create mode 100644 src/material-experimental/dialog/index.ts create mode 100644 src/material-experimental/dialog/public-api.ts create mode 100644 src/material-experimental/dialog/tsconfig-build.json create mode 100644 src/material-experimental/typings.d.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b727d641bbe..d104fce5889 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -74,6 +74,7 @@ # Material experimental package /src/material-experimental/** @jelbourn +/src/material-experimental/dialog/** @jelbourn @josephperrott @crisbeto # Docs examples & guides /guides/** @amcdnl @jelbourn diff --git a/src/material-experimental/dialog/dialog-config.ts b/src/material-experimental/dialog/dialog-config.ts new file mode 100644 index 00000000000..c92ecb2b5e7 --- /dev/null +++ b/src/material-experimental/dialog/dialog-config.ts @@ -0,0 +1,92 @@ +/** + * @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 {ViewContainerRef} from '@angular/core'; +import {Direction} from '@angular/cdk/bidi'; +import {ComponentType} from '@angular/cdk/overlay'; +import {CdkDialogContainer} from './dialog-container'; + +/** Valid ARIA roles for a dialog element. */ +export type DialogRole = 'dialog' | 'alertdialog'; + +/** Possible overrides for a dialog's position. */ +export interface DialogPosition { + top?: string; + bottom?: string; + left?: string; + right?: string; +} + +export class DialogConfig { + /** Component to use as the container for the dialog. */ + containerComponent?: ComponentType; + + /** + * Where the attached component should live in Angular's *logical* component tree. + * This affects what is available for injection and the change detection order for the + * component instantiated inside of the dialog. This does not affect where the dialog + * content will be rendered. + */ + viewContainerRef?: ViewContainerRef; + + /** The id of the dialog. */ + id?: string; + + /** The ARIA role of the dialog. */ + role?: DialogRole = 'dialog'; + + /** Custom class(es) for the overlay panel. */ + panelClass?: string | string[] = ''; + + /** Custom class(es) for the dialog container. */ + containerClass?: string | string[] = ''; + + /** Whether the dialog has a background. */ + hasBackdrop?: boolean = true; + + /** Custom class(es) for the backdrop. */ + backdropClass?: string | undefined = ''; + + /** Whether the dialog can be closed by user interaction. */ + disableClose?: boolean = false; + + /** The width of the dialog. */ + width?: string = ''; + + /** The height of the dialog. */ + height?: string = ''; + + /** The minimum width of the dialog. */ + minWidth?: string | number = ''; + + /** The minimum height of the dialog. */ + minHeight?: string | number = ''; + + /** The maximum width of the dialog. */ + maxWidth?: string | number = '80vw'; + + /** The maximum height of the dialog. */ + maxHeight?: string | number = ''; + + /** The position of the dialog. */ + position?: DialogPosition; + + /** Data to be injected into the dialog content. */ + data?: D | null = null; + + /** The layout direction for the dialog content. */ + direction?: Direction = 'ltr'; + + /** ID of the element that describes the dialog. */ + ariaDescribedBy?: string | null = null; + + /** Aria label to assign to the dialog element */ + ariaLabel?: string | null = null; + + /** Whether the dialog should focus the first focusable element on open. */ + autoFocus?: boolean = true; +} diff --git a/src/material-experimental/dialog/dialog-container.html b/src/material-experimental/dialog/dialog-container.html new file mode 100644 index 00000000000..215f0d9c55b --- /dev/null +++ b/src/material-experimental/dialog/dialog-container.html @@ -0,0 +1 @@ + diff --git a/src/material-experimental/dialog/dialog-container.scss b/src/material-experimental/dialog/dialog-container.scss new file mode 100644 index 00000000000..8eb48baae9d --- /dev/null +++ b/src/material-experimental/dialog/dialog-container.scss @@ -0,0 +1,6 @@ +cdk-dialog-container { + background: white; + border-radius: 5px; + display: block; + padding: 10px; +} diff --git a/src/material-experimental/dialog/dialog-container.ts b/src/material-experimental/dialog/dialog-container.ts new file mode 100644 index 00000000000..2821ddfc7e2 --- /dev/null +++ b/src/material-experimental/dialog/dialog-container.ts @@ -0,0 +1,218 @@ +/** + * @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 { + ElementRef, + HostBinding, + ViewChild, + ComponentRef, + EmbeddedViewRef, + ChangeDetectorRef, + Component, + Optional, + Inject, + ViewEncapsulation, + ChangeDetectionStrategy, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {trigger, state, style, transition, animate, AnimationEvent} from '@angular/animations'; +import { + BasePortalOutlet, + PortalHostDirective, + ComponentPortal, + TemplatePortal +} from '@angular/cdk/portal'; +import {FocusTrapFactory} from '@angular/cdk/a11y'; +import {DialogConfig} from './dialog-config'; +import {Subject} from 'rxjs/Subject'; + + +export function throwDialogContentAlreadyAttachedError() { + throw Error('Attempting to attach dialog content after content is already attached'); +} + + +/** + * Internal component that wraps user-provided dialog content. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'cdk-dialog-container', + templateUrl: './dialog-container.html', + styleUrls: ['dialog-container.css'], + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + // 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: [ + trigger('dialog', [ + state('enter', style({ opacity: 1 })), + state('exit, void', style({ opacity: 0 })), + transition('* => *', animate(225)), + ]) + ], + host: { + '[@dialog]': '_state', + '(@dialog.start)': '_onAnimationStart($event)', + '(@dialog.done)': '_onAnimationDone($event)', + }, +}) +export class CdkDialogContainer extends BasePortalOutlet { + /** State of the dialog animation. */ + _state: 'void' | 'enter' | 'exit' = 'enter'; + + /** Element that was focused before the dialog was opened. Save this to restore upon close. */ + private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null; + + /** The class that traps and manages focus within the dialog. */ + private _focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, false); + + // @HostBinding is used in the class as it is expected to be extended. Since @Component decorator + // metadata is not inherited by child classes, instead the host binding data is defined in a way + // that can be inherited. + // tslint:disable:no-host-decorator-in-concrete + @HostBinding('attr.aria-label') get _ariaLabel() { return this._config.ariaLabel || null; } + + @HostBinding('attr.aria-describedby') + get _ariaDescribedBy() { return this._config ? this._config.ariaDescribedBy : null; } + + @HostBinding('attr.role') get _role() { return this._config ? this._config.role : null; } + + @HostBinding('attr.tabindex') get _tabindex() { return -1; } + // tslint:disable:no-host-decorator-in-concrete + + /** The portal host inside of this container into which the dialog content will be loaded. */ + @ViewChild(PortalHostDirective) _portalHost: PortalHostDirective; + + /** A subject emitting before the dialog enters the view. */ + _beforeEnter: Subject = new Subject(); + + /** A subject emitting after the dialog enters the view. */ + _afterEnter: Subject = new Subject(); + + /** A subject emitting before the dialog exits the view. */ + _beforeExit: Subject = new Subject(); + + /** A subject emitting after the dialog exits the view. */ + _afterExit: Subject = new Subject(); + + /** The dialog configuration. */ + _config: DialogConfig; + + constructor( + private _elementRef: ElementRef, + private _focusTrapFactory: FocusTrapFactory, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(DOCUMENT) private _document: any) { + super(); + } + + /** Destroy focus trap to place focus back to the element focused before the dialog opened. */ + ngOnDestroy() { + this._focusTrap.destroy(); + } + + /** + * Attach a ComponentPortal as content to this dialog container. + * @param portal Portal to be attached as the dialog content. + */ + attachComponentPortal(portal: ComponentPortal): ComponentRef { + if (this._portalHost.hasAttached()) { + throwDialogContentAlreadyAttachedError(); + } + + this._savePreviouslyFocusedElement(); + return this._portalHost.attachComponentPortal(portal); + } + + /** + * Attach a TemplatePortal as content to this dialog container. + * @param portal Portal to be attached as the dialog content. + */ + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this._portalHost.hasAttached()) { + throwDialogContentAlreadyAttachedError(); + } + + this._savePreviouslyFocusedElement(); + return this._portalHost.attachTemplatePortal(portal); + } + + /** Emit lifecycle events based on animation `start` callback. */ + _onAnimationStart(event: AnimationEvent) { + if (event.toState === 'enter') { + this._beforeEnter.next(); + } + if (event.toState === 'void' || event.toState === 'exit') { + this._beforeExit.next(); + } + } + + /** Emit lifecycle events based on animation `done` callback. */ + _onAnimationDone(event: AnimationEvent) { + if (event.toState === 'enter') { + this._autoFocusFirstTabbableElement(); + this._afterEnter.next(); + } + if (event.toState === 'void' || event.toState === 'exit') { + this._returnFocusAfterDialog(); + this._afterExit.next(); + } + } + + /** Starts the dialog exit animation. */ + _startExiting(): void { + this._state = 'exit'; + + // Mark the container for check so it can react if the + // view container is using OnPush change detection. + this._changeDetectorRef.markForCheck(); + } + + /** Saves a reference to the element that was focused before the dialog was opened. */ + 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()); + } + } + + /** + * Autofocus the first tabbable element inside of the dialog, if there is not a tabbable element, + * focus the dialog instead. + */ + private _autoFocusFirstTabbableElement() { + // If 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. + if (this._config.autoFocus) { + this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => { + // If we didn't find any focusable elements inside the dialog, focus the + // container so the user can't tab into other elements behind it. + if (!hasMovedFocus) { + this._elementRef.nativeElement.focus(); + } + }); + } + } + + /** Returns the focus to the element focused before the dialog was open. */ + private _returnFocusAfterDialog() { + const toFocus = this._elementFocusedBeforeDialogWasOpened; + // We need the extra check, because IE can set the `activeElement` to null in some cases. + if (toFocus && typeof toFocus.focus === 'function') { + toFocus.focus(); + } + } +} diff --git a/src/material-experimental/dialog/dialog-injectors.ts b/src/material-experimental/dialog/dialog-injectors.ts new file mode 100644 index 00000000000..ec9d4c513a1 --- /dev/null +++ b/src/material-experimental/dialog/dialog-injectors.ts @@ -0,0 +1,43 @@ +/** + * @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 {InjectionToken} from '@angular/core'; +import {ComponentType, Overlay, ScrollStrategy, BlockScrollStrategy} from '@angular/cdk/overlay'; +import {DialogRef} from './dialog-ref'; +import {CdkDialogContainer} from './dialog-container'; +import {DialogConfig} from './dialog-config'; + +/** Injection token for the Dialog's ScrollStrategy. */ +export const DIALOG_SCROLL_STRATEGY = + new InjectionToken<() => ScrollStrategy>('DialogScrollStrategy'); + +/** Injection token for the Dialog's Data. */ +export const DIALOG_DATA = new InjectionToken('DialogData'); + +/** Injection token for the DialogRef constructor. */ +export const DIALOG_REF = new InjectionToken>('DialogRef'); + +/** Injection token for the DialogConfig. */ +export const DIALOG_CONFIG = new InjectionToken('DialogConfig'); + +/** Injection token for the Dialog's DialogContainer component. */ +export const DIALOG_CONTAINER = + new InjectionToken>('DialogContainer'); + +/** @docs-private */ +export function MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay): + () => BlockScrollStrategy { + return () => overlay.scrollStrategies.block(); +} + +/** @docs-private */ +export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = { + provide: DIALOG_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, +}; diff --git a/src/material-experimental/dialog/dialog-module.ts b/src/material-experimental/dialog/dialog-module.ts new file mode 100644 index 00000000000..bf3a8b39d13 --- /dev/null +++ b/src/material-experimental/dialog/dialog-module.ts @@ -0,0 +1,48 @@ +/** + * @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 {CommonModule} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {PortalModule} from '@angular/cdk/portal'; +import {A11yModule} from '@angular/cdk/a11y'; +import {Dialog} from './dialog'; +import {CdkDialogContainer} from './dialog-container'; +import {DialogConfig} from './dialog-config'; +import {DialogRef} from './dialog-ref'; +import { + DIALOG_CONFIG, + DIALOG_CONTAINER, + DIALOG_REF, + MAT_DIALOG_SCROLL_STRATEGY_PROVIDER +} from './dialog-injectors'; + + +@NgModule({ + imports: [ + CommonModule, + OverlayModule, + PortalModule, + A11yModule, + ], + exports: [ + CdkDialogContainer, + ], + declarations: [ + CdkDialogContainer, + ], + providers: [ + Dialog, + MAT_DIALOG_SCROLL_STRATEGY_PROVIDER, + {provide: DIALOG_REF, useValue: DialogRef}, + {provide: DIALOG_CONTAINER, useValue: CdkDialogContainer}, + {provide: DIALOG_CONFIG, useValue: DialogConfig}, + ], + entryComponents: [CdkDialogContainer], +}) +export class DialogModule {} diff --git a/src/material-experimental/dialog/dialog-ref.ts b/src/material-experimental/dialog/dialog-ref.ts new file mode 100644 index 00000000000..39f304154d8 --- /dev/null +++ b/src/material-experimental/dialog/dialog-ref.ts @@ -0,0 +1,142 @@ +/** + * @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, GlobalPositionStrategy, OverlaySizeConfig} from '@angular/cdk/overlay'; +import {Observable} from 'rxjs/Observable'; +import {map} from 'rxjs/operators/map'; +import {DialogPosition} from './dialog-config'; +import {CdkDialogContainer} from './dialog-container'; + +/** Unique id for the created dialog. */ +let uniqueId = 0; + +/** + * Reference to a dialog opened via the Dialog service. + */ +export class DialogRef { + /** The instance of the component in the dialog. */ + componentInstance: T; + + /** Whether the user is allowed to close the dialog. */ + disableClose: boolean | undefined; + + /** Result to be passed to afterClosed. */ + private _result: R | undefined; + + constructor( + public _overlayRef: OverlayRef, + protected _containerInstance: CdkDialogContainer, + readonly id: string = `dialog-${uniqueId++}`) { + + // If the dialog has a backdrop, handle clicks from the backdrop. + if (_containerInstance._config.hasBackdrop) { + _overlayRef.backdropClick().subscribe(() => { + if (!this.disableClose) { + this.close(); + } + }); + } + + this.beforeClose().subscribe(() => { + this._overlayRef.detachBackdrop(); + }); + + this.afterClosed().subscribe(() => { + this._overlayRef.detach(); + this._overlayRef.dispose(); + this.componentInstance = null!; + }); + } + + /** Gets an observable that emits when the overlay's backdrop has been clicked. */ + backdropClick(): Observable { + return this._overlayRef.backdropClick(); + } + + /** + * Close the dialog. + * @param dialogResult Optional result to return to the dialog opener. + */ + close(dialogResult?: R): void { + this._result = dialogResult; + this._containerInstance._startExiting(); + } + + /** + * Updates the dialog's position. + * @param position New dialog position. + */ + updatePosition(position?: DialogPosition): this { + let strategy = this._getPositionStrategy(); + + if (position && (position.left || position.right)) { + position.left ? strategy.left(position.left) : strategy.right(position.right); + } else { + strategy.centerHorizontally(); + } + + if (position && (position.top || position.bottom)) { + position.top ? strategy.top(position.top) : strategy.bottom(position.bottom); + } else { + strategy.centerVertically(); + } + + this._overlayRef.updatePosition(); + + return this; + } + + /** + * Gets an observable that emits when keydown events are targeted on the overlay. + */ + keydownEvents(): Observable { + return this._overlayRef.keydownEvents(); + } + + /** + * Updates the dialog's width and height, defined, min and max. + * @param size New size for the overlay. + */ + updateSize(size: OverlaySizeConfig): this { + if (size.width) { + this._getPositionStrategy().width(size.width.toString()); + } + if (size.height) { + this._getPositionStrategy().height(size.height.toString()); + } + this._overlayRef.updateSize(size); + this._overlayRef.updatePosition(); + return this; + } + + /** Fetches the position strategy object from the overlay ref. */ + private _getPositionStrategy(): GlobalPositionStrategy { + return this._overlayRef.getConfig().positionStrategy as GlobalPositionStrategy; + } + + /** Gets an observable that emits when dialog begins opening. */ + beforeOpen(): Observable { + return this._containerInstance._beforeEnter.asObservable(); + } + + /** Gets an observable that emits when dialog is finished opening. */ + afterOpen(): Observable { + return this._containerInstance._afterEnter.asObservable(); + } + + /** Gets an observable that emits when dialog begins closing. */ + beforeClose(): Observable { + return this._containerInstance._beforeExit.pipe(map(() => this._result)); + } + + /** Gets an observable that emits when dialog is finished closing. */ + afterClosed(): Observable { + return this._containerInstance._afterExit.pipe(map(() => this._result)); + } +} diff --git a/src/material-experimental/dialog/dialog.spec.ts b/src/material-experimental/dialog/dialog.spec.ts new file mode 100644 index 00000000000..6e6bb1d7b12 --- /dev/null +++ b/src/material-experimental/dialog/dialog.spec.ts @@ -0,0 +1,1084 @@ +import { + ComponentFixture, + fakeAsync, + flushMicrotasks, + inject, + TestBed, + tick, + flush, +} from '@angular/core/testing'; +import { + ChangeDetectionStrategy, + Component, + Directive, + Inject, + Injector, + NgModule, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {Location} from '@angular/common'; +import {SpyLocation} from '@angular/common/testing'; +import {Directionality} from '@angular/cdk/bidi'; +import {CdkDialogContainer} from './dialog-container'; +import {OverlayContainer} from '@angular/cdk/overlay'; +import {A, ESCAPE} from '@angular/cdk/keycodes'; +import {dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {DIALOG_DATA, Dialog, DialogModule, DialogRef} from './index'; + +describe('Dialog', () => { + let dialog: Dialog; + let overlayContainerElement: HTMLElement; + + let testViewContainerRef: ViewContainerRef; + let viewContainerFixture: ComponentFixture; + let mockLocation: SpyLocation; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DialogModule, DialogTestModule], + providers: [ + {provide: OverlayContainer, useFactory: () => { + overlayContainerElement = document.createElement('div'); + return {getContainerElement: () => overlayContainerElement}; + }}, + {provide: Location, useClass: SpyLocation} + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([Dialog, Location], (d: Dialog, l: Location) => { + dialog = d; + mockLocation = l as SpyLocation; + })); + + beforeEach(() => { + viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer); + + viewContainerFixture.detectChanges(); + testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; + }); + + it('should open a dialog with a component', () => { + let dialogRef = dialog.openFromComponent(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(); + viewContainerFixture.detectChanges(); + let dialogContainerElement = overlayContainerElement.querySelector('cdk-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.openFromTemplate( + 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('cdk-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + + dialogRef.close(); + }); + + it('should emit when dialog opening animation is complete', fakeAsync(() => { + const dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const spy = jasmine.createSpy('afterOpen spy'); + + dialogRef.afterOpen().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.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + let dialogInjector = dialogRef.componentInstance.dialogInjector as Injector; + + 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.openFromComponent(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('cdk-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + }); + + it('should apply the configured role to the dialog element', () => { + dialog.openFromComponent(PizzaMsg, { role: 'alertdialog' }); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('cdk-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('alertdialog'); + }); + + it('should apply the specified `aria-describedby`', () => { + dialog.openFromComponent(PizzaMsg, { ariaDescribedBy: 'description-element' }); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('cdk-dialog-container')!; + expect(dialogContainerElement.getAttribute('aria-describedby')).toBe('description-element'); + }); + + it('should close a dialog and get back a result', fakeAsync(() => { + let dialogRef = dialog.openFromComponent(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('cdk-dialog-container')).toBeNull(); + })); + + it('should close a dialog and get back a result before it is closed', fakeAsync(() => { + const dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + // beforeClose should emit before dialog container is destroyed + const beforeCloseHandler = jasmine.createSpy('beforeClose callback').and.callFake(() => { + expect(overlayContainerElement.querySelector('cdk-dialog-container')) + .not.toBeNull('dialog container exists when beforeClose is called'); + }); + + dialogRef.beforeClose().subscribe(beforeCloseHandler); + dialogRef.close('Bulbasaurus'); + viewContainerFixture.detectChanges(); + flush(); + + expect(beforeCloseHandler).toHaveBeenCalledWith('Bulbasaurus'); + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeNull(); + })); + + it('should close a dialog via the escape key', fakeAsync(() => { + dialog.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef + }); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeNull(); + })); + + it('should close from a ViewContainerRef with OnPush change detection', fakeAsync(() => { + const onPushFixture = TestBed.createComponent(ComponentWithOnPushViewContainer); + + onPushFixture.detectChanges(); + + const dialogRef = dialog.openFromComponent(PizzaMsg, { + viewContainerRef: onPushFixture.componentInstance.viewContainerRef + }); + + flushMicrotasks(); + onPushFixture.detectChanges(); + flushMicrotasks(); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length) + .toBe(1, 'Expected one open dialog.'); + + dialogRef.close(); + flushMicrotasks(); + onPushFixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length) + .toBe(0, 'Expected no open dialogs.'); + })); + + it('should close when clicking on the overlay backdrop', fakeAsync(() => { + dialog.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeFalsy(); + })); + + it('should emit the backdropClick stream when clicking on the overlay backdrop', fakeAsync(() => { + const dialogRef = dialog.openFromComponent(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.openFromComponent(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('cdk-dialog-container') as HTMLElement; + dispatchKeyboardEvent(document.body, 'keydown', A); + dispatchKeyboardEvent(document.body, 'keydown', A, backdrop); + dispatchKeyboardEvent(document.body, 'keydown', A, container); + + expect(spy).toHaveBeenCalledTimes(3); + })); + + it('should notify the observers if a dialog has been opened', () => { + dialog.afterOpen.subscribe(ref => { + expect(dialog.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef + })).toBe(ref); + }); + }); + + it('should notify the observers if all open dialogs have finished closing', fakeAsync(() => { + const ref1 = dialog.openFromComponent(PizzaMsg, { viewContainerRef: testViewContainerRef }); + const ref2 = dialog.openFromComponent( + 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(); + viewContainerFixture.detectChanges(); + 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 should override the width of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + width: '500px' + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.width).toBe('500px'); + }); + + it('should should override the height of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + height: '100px' + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.height).toBe('100px'); + }); + + it('should should override the min-width of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + minWidth: '500px' + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.minWidth).toBe('500px'); + }); + + it('should should override the max-width of the overlay pane', fakeAsync(() => { + let dialogRef = dialog.openFromComponent(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.openFromComponent(PizzaMsg, { + maxWidth: '100px' + }); + + tick(500); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + expect(overlayPane.style.maxWidth).toBe('100px'); + })); + + it('should should override the min-height of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + minHeight: '300px' + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.minHeight).toBe('300px'); + }); + + it('should should override the max-height of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + maxHeight: '100px' + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.maxHeight).toBe('100px'); + }); + + it('should should override the top offset of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + position: { + top: '100px' + } + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginTop).toBe('100px'); + }); + + it('should should override the bottom offset of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + position: { + bottom: '200px' + } + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginBottom).toBe('200px'); + }); + + it('should should override the left offset of the overlay pane', () => { + dialog.openFromComponent(PizzaMsg, { + position: { + left: '250px' + } + }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.marginLeft).toBe('250px'); + }); + + it('should should override the right offset of the overlay pane', () => { + dialog.openFromComponent(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.openFromComponent(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.openFromComponent(PizzaMsg, { width: '100px' }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; + + expect(overlayPane.style.width).toBe('100px'); + + dialogRef.updateSize({width: '200px'}); + + expect(overlayPane.style.width).toBe('200px'); + }); + + it('should allow setting the layout direction', () => { + dialog.openFromComponent(PizzaMsg, { direction: 'rtl' }); + + viewContainerFixture.detectChanges(); + + let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane')!; + + expect(overlayPane.getAttribute('dir')).toBe('rtl'); + }); + + it('should inject the correct layout direction in the component instance', () => { + const dialogRef = dialog.openFromComponent(PizzaMsg, { direction: 'rtl' }); + + viewContainerFixture.detectChanges(); + + expect(dialogRef.componentInstance.directionality.value).toBe('rtl'); + }); + + it('should close all of the dialogs', fakeAsync(() => { + dialog.openFromComponent(PizzaMsg); + dialog.openFromComponent(PizzaMsg); + dialog.openFromComponent(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(3); + + dialog.closeAll(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0); + })); + + it('should set the proper animation states', () => { + let dialogRef = dialog.openFromComponent(PizzaMsg, { viewContainerRef: testViewContainerRef }); + let dialogContainer: CdkDialogContainer = + viewContainerFixture.debugElement.query(By.directive(CdkDialogContainer)).componentInstance; + + expect(dialogContainer._state).toBe('enter'); + + dialogRef.close(); + + expect(dialogContainer._state).toBe('exit'); + }); + + it('should close all dialogs when the user goes forwards/backwards in history', fakeAsync(() => { + dialog.openFromComponent(PizzaMsg); + dialog.openFromComponent(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2); + + mockLocation.simulateUrlPop(''); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0); + })); + + it('should close all open dialogs when the location hash changes', fakeAsync(() => { + dialog.openFromComponent(PizzaMsg); + dialog.openFromComponent(PizzaMsg); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2); + + mockLocation.simulateHashChange(''); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0); + })); + + it('should have the componentInstance available in the afterClosed callback', fakeAsync(() => { + let dialogRef = dialog.openFromComponent(PizzaMsg); + let spy = jasmine.createSpy('afterClosed spy'); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + dialogRef.afterClosed().subscribe(() => { + spy(); + }); + + dialogRef.close(); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + // Ensure that the callback actually fires. + expect(spy).toHaveBeenCalled(); + })); + + describe('passing in data', () => { + it('should be able to pass in data', () => { + let config = { + data: { + stringParam: 'hello', + dateParam: new Date() + } + }; + + let instance = dialog.openFromComponent(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.openFromComponent(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.openFromComponent(PizzaMsg); + + expect(dialogRef.componentInstance).toBeTruthy(); + + dialogRef.close(); + flush(); + 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.openFromComponent(PizzaMsg); + const two = dialog.openFromComponent(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.openFromComponent(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.openFromComponent(PizzaMsg, { id: 'pizza' }); + expect(() => dialog.openFromComponent(PizzaMsg, { id: 'pizza' })) + .toThrowError(/must be unique/g); + }); + + it('should be able to find a dialog by id', () => { + const dialogRef = dialog.openFromComponent(PizzaMsg, { id: 'pizza' }); + expect(dialog.getById('pizza')).toBe(dialogRef); + }); + + describe('disableClose option', () => { + it('should prevent closing via clicks on the backdrop', () => { + dialog.openFromComponent(PizzaMsg, { + disableClose: true, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeTruthy(); + }); + + it('should prevent closing via the escape key', () => { + dialog.openFromComponent(PizzaMsg, { + disableClose: true, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeTruthy(); + }); + + it('should allow for the disableClose option to be updated while open', fakeAsync(() => { + let dialogRef = dialog.openFromComponent(PizzaMsg, { + disableClose: true, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + backdrop.click(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeTruthy(); + + dialogRef.disableClose = false; + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeFalsy(); + })); + }); + + describe('hasBackdrop option', () => { + it('should have a backdrop', () => { + dialog.openFromComponent(PizzaMsg, { + hasBackdrop: true, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy(); + }); + + it('should not have a backdrop', () => { + dialog.openFromComponent(PizzaMsg, { + hasBackdrop: false, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy(); + }); + }); + + describe('panelClass option', () => { + it('should have custom panel class', () => { + dialog.openFromComponent(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.openFromComponent(PizzaMsg, { + backdropClass: '', + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.cdk-overlay-dark-backdrop')).toBeTruthy(); + }); + + it('should have custom backdrop class', () => { + dialog.openFromComponent(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.openFromComponent(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.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: false + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement.tagName).not.toBe('INPUT'); + })); + + 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.openFromComponent( + 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(); + flush(); + + expect(document.activeElement.id).toBe('dialog-trigger', + 'Expected that the trigger was refocused after the dialog is closed.'); + + 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.openFromComponent( + PizzaMsg, { viewContainerRef: testViewContainerRef }); + + tick(500); + viewContainerFixture.detectChanges(); + + dialogRef.afterClosed().subscribe(() => input.focus()); + dialogRef.close(); + + tick(500); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + 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.openFromComponent(DialogWithoutFocusableElements); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement.tagName.toLowerCase()) + .toBe('cdk-dialog-container', 'Expected dialog container to be focused.'); + })); + + }); + + describe('aria-label', () => { + it('should be able to set a custom aria-label', () => { + dialog.openFromComponent(PizzaMsg, { + ariaLabel: 'Hello there', + viewContainerRef: testViewContainerRef + }); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('cdk-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.openFromComponent(ContentElementDialog, { + ariaLabel: 'Hello there', + viewContainerRef: testViewContainerRef + }); + viewContainerFixture.detectChanges(); + tick(); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('cdk-dialog-container')!; + expect(container.hasAttribute('aria-labelledby')).toBe(false); + })); + }); +}); + +describe('Dialog with a parent Dialog', () => { + let parentDialog: Dialog; + let childDialog: Dialog; + let overlayContainerElement: HTMLElement; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [DialogModule, DialogTestModule], + declarations: [ComponentThatProvidesMatDialog], + providers: [ + {provide: OverlayContainer, useFactory: () => { + overlayContainerElement = document.createElement('div'); + return {getContainerElement: () => overlayContainerElement}; + }}, + {provide: Location, useClass: SpyLocation} + ], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([Dialog], (d: Dialog) => { + 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 Dialog', + fakeAsync(() => { + parentDialog.openFromComponent(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 Dialog to close dialog opened by parent'); + })); + + it('should close dialogs opened by a child when calling closeAll on a parent Dialog', + fakeAsync(() => { + childDialog.openFromComponent(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 Dialog to close dialog opened by child'); + })); + + it('should close the top dialog via the escape key', fakeAsync(() => { + childDialog.openFromComponent(PizzaMsg); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + fixture.detectChanges(); + flush(); + + expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeNull(); + })); +}); + + +@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: DialogRef; + + @ViewChild(TemplateRef) templateRef: TemplateRef; + + setDialogRef(dialogRef: DialogRef): string { + this.dialogRef = dialogRef; + return ''; + } +} + +/** Simple component for testing ComponentPortal. */ +@Component({template: '

Pizza

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

This is the title

+ ` +}) +class ContentElementDialog { + closeButtonAriaLabel: string; +} + +@Component({ + template: '', + providers: [Dialog] +}) +class ComponentThatProvidesMatDialog { + constructor(public dialog: Dialog) {} +} + +/** Simple component for testing ComponentPortal. */ +@Component({template: ''}) +class DialogWithInjectedData { + constructor(@Inject(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 +]; + +@NgModule({ + imports: [DialogModule, NoopAnimationsModule], + exports: TEST_DIRECTIVES, + declarations: TEST_DIRECTIVES, + entryComponents: [ + ComponentWithChildViewContainer, + ComponentWithTemplateRef, + PizzaMsg, + ContentElementDialog, + DialogWithInjectedData, + DialogWithoutFocusableElements, + ], +}) +class DialogTestModule { } diff --git a/src/material-experimental/dialog/dialog.ts b/src/material-experimental/dialog/dialog.ts new file mode 100644 index 00000000000..594211dd8d4 --- /dev/null +++ b/src/material-experimental/dialog/dialog.ts @@ -0,0 +1,309 @@ +/** + * @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 { + TemplateRef, + SkipSelf, + Optional, + Injector, + Inject, + ComponentRef +} from '@angular/core'; +import {ComponentPortal, PortalInjector, TemplatePortal} from '@angular/cdk/portal'; +import {ESCAPE} from '@angular/cdk/keycodes'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; +import {DialogRef} from './dialog-ref'; +import {Location} from '@angular/common'; +import {DialogConfig} from './dialog-config'; +import {Directionality} from '@angular/cdk/bidi'; +import {of as observableOf} from 'rxjs/observable/of'; +import {CdkDialogContainer} from './dialog-container'; +import { + ComponentType, + Overlay, + OverlayRef, + OverlayConfig, +} from '@angular/cdk/overlay'; +import {startWith} from 'rxjs/operators/startWith'; +import {defer} from 'rxjs/observable/defer'; + +import { + DIALOG_SCROLL_STRATEGY, + DIALOG_DATA, + DIALOG_REF, + DIALOG_CONTAINER, + DIALOG_CONFIG, +} from './dialog-injectors'; + + +/** + * Service to open modal dialogs. + */ +export class Dialog { + /** A the handleKeydown method bound to the class instance. */ + private _boundKeydown = this._handleKeydown.bind(this); + + /** Stream that emits when all dialogs are closed. */ + get _afterAllClosed(): Observable { + return this._parentDialog ? this._parentDialog.afterAllClosed : this._afterAllClosedBase; + } + _afterAllClosedBase = new Subject(); + afterAllClosed: Observable = defer(() => this.openDialogs.length ? + this._afterAllClosed : this._afterAllClosed.pipe(startWith(undefined))); + + /** Stream that emits when a dialog is opened. */ + get afterOpen(): Subject> { + return this._parentDialog ? this._parentDialog.afterOpen : this._afterOpen; + } + _afterOpen: Subject> = new Subject(); + + /** Stream that emits when a dialog is opened. */ + get openDialogs(): DialogRef[] { + return this._parentDialog ? this._parentDialog.openDialogs : this._openDialogs; + } + _openDialogs: DialogRef[] = []; + + constructor( + private overlay: Overlay, + private injector: Injector, + @Inject(DIALOG_REF) private dialogRefConstructor, + @Inject(DIALOG_SCROLL_STRATEGY) private _scrollStrategy, + @Optional() @SkipSelf() private _parentDialog: Dialog, + @Optional() location: Location) { + + // Close all of the dialogs when the user goes forwards/backwards in history or when the + // location hash changes. Note that this usually doesn't include clicking on links (unless + // the user is using the `HashLocationStrategy`). + if (!_parentDialog && location) { + location.subscribe(() => this.closeAll()); + } + + this.afterAllClosed.subscribe(() => { + document.removeEventListener('keydown', this._boundKeydown); + }); + } + + /** Gets an open dialog by id. */ + getById(id: string): DialogRef | undefined { + return this._openDialogs.find(ref => ref.id === id); + } + + /** Closes all open dialogs. */ + closeAll(): void { + this.openDialogs.forEach(ref => ref.close()); + } + + /** Opens a dialog from a component. */ + openFromComponent(component: ComponentType, config?: DialogConfig): DialogRef { + config = this._applyConfigDefaults(config); + + if (config.id && this.getById(config.id)) { + throw Error(`Dialog with id "${config.id}" exists already. The dialog id must be unique.`); + } + + const overlayRef = this._createOverlay(config); + const dialogContainer = this._attachDialogContainer(overlayRef, config); + const dialogRef = this._attachDialogContentForComponent(component, dialogContainer, + overlayRef, config); + + this.registerDialogRef(dialogRef); + return dialogRef; + } + + /** Opens a dialog from a template. */ + openFromTemplate(template: TemplateRef, config?: DialogConfig): DialogRef { + config = this._applyConfigDefaults(config); + + if (config.id && this.getById(config.id)) { + throw Error(`Dialog with id "${config.id}" exists already. The dialog id must be unique.`); + } + + const overlayRef = this._createOverlay(config); + const dialogContainer = this._attachDialogContainer(overlayRef, config); + const dialogRef = this._attachDialogContentForTemplate(template, dialogContainer, + overlayRef, config); + + this.registerDialogRef(dialogRef); + return dialogRef; + } + + /** + * Registers a keydown listener, and forwards emitting events for when dialogs are opened and all + * dialogs are closed. + */ + private registerDialogRef(dialogRef: DialogRef): void { + if (!this.openDialogs.length) { + document.addEventListener('keydown', this._boundKeydown); + } + this.openDialogs.push(dialogRef); + let dialogOpenSub = dialogRef.afterOpen().subscribe(() => { + this.afterOpen.next(dialogRef); + dialogOpenSub.unsubscribe(); + }); + let dialogCloseSub = dialogRef.afterClosed().subscribe(() => { + let dialogIdx = this._openDialogs.indexOf(dialogRef); + if (dialogIdx !== -1) { this._openDialogs.splice(dialogIdx, 1); } + + if (!this._openDialogs.length) { + this._afterAllClosedBase.next(); + dialogCloseSub.unsubscribe(); + } + }); + } + + /** + * Handles global key presses while there are open dialogs. Closes the + * top dialog when the user presses escape. + */ + private _handleKeydown(event: KeyboardEvent): void { + const topDialog = this.openDialogs[this.openDialogs.length - 1]; + const canClose = topDialog ? !topDialog.disableClose : false; + + if (event.keyCode === ESCAPE && canClose) { + topDialog.close(); + } + } + + + /** + * Creates an overlay config from a dialog config. + * @param config The dialog configuration. + * @returns The overlay configuration. + */ + protected _createOverlay(config: DialogConfig): OverlayRef { + const overlayConfig = new OverlayConfig({ + positionStrategy: this.overlay.position().global(), + scrollStrategy: this._scrollStrategy(), + panelClass: config.panelClass, + hasBackdrop: config.hasBackdrop, + direction: config.direction, + minWidth: config.minWidth, + minHeight: config.minHeight, + maxWidth: config.maxWidth, + maxHeight: config.maxHeight + }); + + if (config.backdropClass) { + overlayConfig.backdropClass = config.backdropClass; + } + return this.overlay.create(overlayConfig); + } + + + /** + * Attaches an MatDialogContainer 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. + */ + protected _attachDialogContainer(overlay: OverlayRef, config: DialogConfig): CdkDialogContainer { + const container = config.containerComponent || this.injector.get(DIALOG_CONTAINER); + let containerPortal = new ComponentPortal(container, config.viewContainerRef); + let containerRef: ComponentRef = overlay.attach(containerPortal); + containerRef.instance._config = config; + + return containerRef.instance; + } + + + /** + * Attaches the user-provided component to the already-created MatDialogContainer. + * @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 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. + */ + protected _attachDialogContentForComponent( + componentOrTemplateRef: ComponentType, + dialogContainer: CdkDialogContainer, + overlayRef: OverlayRef, + config: DialogConfig): DialogRef { + + // 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 this.dialogRefConstructor(overlayRef, dialogContainer, config.id); + + const injector = this._createInjector(config, dialogRef, dialogContainer); + const contentRef = dialogContainer.attachComponentPortal( + new ComponentPortal(componentOrTemplateRef, undefined, injector)); + dialogRef.componentInstance = contentRef.instance; + + dialogRef.updateSize({width: config.width, height: config.height}) + .updatePosition(config.position); + + return dialogRef; + } + + /** + * Attaches the user-provided component to the already-created MatDialogContainer. + * @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 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. + */ + protected _attachDialogContentForTemplate( + componentOrTemplateRef: TemplateRef, + dialogContainer: CdkDialogContainer, + overlayRef: OverlayRef, + config: DialogConfig): DialogRef { + + // 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 this.dialogRefConstructor(overlayRef, dialogContainer, config.id); + + dialogContainer.attachTemplatePortal( + new TemplatePortal(componentOrTemplateRef, null!, + {$implicit: config.data, dialogRef})); + dialogRef.updateSize({width: config.width, height: config.height}) + .updatePosition(config.position); + + return dialogRef; + } + + + /** + * Creates a custom injector to be used inside the dialog. This allows a component loaded inside + * 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. + * @returns The custom injector that can be used inside the dialog. + */ + private _createInjector( + config: DialogConfig, + dialogRef: DialogRef, + dialogContainer: CdkDialogContainer): PortalInjector { + + const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; + const injectionTokens = new WeakMap(); + + injectionTokens.set(this.injector.get(DIALOG_REF), dialogRef); + injectionTokens.set(this.injector.get(DIALOG_CONTAINER), dialogContainer); + injectionTokens.set(DIALOG_DATA, config.data); + injectionTokens.set(Directionality, { + value: config.direction, + change: observableOf() + }); + + return new PortalInjector(userInjector || this.injector, injectionTokens); + } + + /** + * Expands the provided configuration object to include the default values for properties which + * are undefined. + */ + private _applyConfigDefaults(config?: DialogConfig): DialogConfig { + const dialogConfig = this.injector.get(DIALOG_CONFIG) as typeof DialogConfig; + return {...new dialogConfig(), ...config}; + } +} diff --git a/src/material-experimental/dialog/index.ts b/src/material-experimental/dialog/index.ts new file mode 100644 index 00000000000..676ca90f1ff --- /dev/null +++ b/src/material-experimental/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/dialog/public-api.ts b/src/material-experimental/dialog/public-api.ts new file mode 100644 index 00000000000..1067bbadeef --- /dev/null +++ b/src/material-experimental/dialog/public-api.ts @@ -0,0 +1,14 @@ +/** + * @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-container'; +export * from './dialog-config'; +export * from './dialog-ref'; +export * from './dialog'; +export * from './dialog-module'; +export * from './dialog-injectors'; diff --git a/src/material-experimental/dialog/tsconfig-build.json b/src/material-experimental/dialog/tsconfig-build.json new file mode 100644 index 00000000000..72d41cc8a86 --- /dev/null +++ b/src/material-experimental/dialog/tsconfig-build.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/material-experimental/dialog", + "skipTemplateCodegen": true + } +} diff --git a/src/material-experimental/tsconfig-build.json b/src/material-experimental/tsconfig-build.json index ee192d0d714..83102880b96 100644 --- a/src/material-experimental/tsconfig-build.json +++ b/src/material-experimental/tsconfig-build.json @@ -27,7 +27,8 @@ } }, "files": [ - "public-api.ts" + "public-api.ts", + "typings.d.ts" ], "angularCompilerOptions": { "annotateForClosureCompiler": true, diff --git a/src/material-experimental/typings.d.ts b/src/material-experimental/typings.d.ts new file mode 100644 index 00000000000..ce4ae9b66cf --- /dev/null +++ b/src/material-experimental/typings.d.ts @@ -0,0 +1 @@ +declare var module: {id: string};