diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 23794dba5fe2..9aaca9909e76 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -153,6 +153,7 @@ /src/dev-app/mdc-autocomplete/** @crisbeto /src/dev-app/button/** @andrewseguin /src/dev-app/card/** @andrewseguin +/src/dev-app/cdk-dialog/** @crisbeto /src/dev-app/cdk-experimental-combobox/** @jelbourn /src/dev-app/cdk-experimental-listbox/** @jelbourn /src/dev-app/cdk-experimental-menu/** @jelbourn diff --git a/goldens/ts-circular-deps.json b/goldens/ts-circular-deps.json index 8405fc95263a..46eea5a9d942 100644 --- a/goldens/ts-circular-deps.json +++ b/goldens/ts-circular-deps.json @@ -1,8 +1,4 @@ [ - [ - "src/cdk-experimental/dialog/dialog-config.ts", - "src/cdk-experimental/dialog/dialog-container.ts" - ], ["src/cdk/drag-drop/directives/drag.ts", "src/cdk/drag-drop/directives/drop-list.ts"], ["src/cdk/drag-drop/directives/drag.ts", "src/cdk/drag-drop/drag-events.ts"], [ diff --git a/src/cdk-experimental/dialog/BUILD.bazel b/src/cdk-experimental/dialog/BUILD.bazel index 6e865271a412..a38aef4827a5 100644 --- a/src/cdk-experimental/dialog/BUILD.bazel +++ b/src/cdk-experimental/dialog/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite", "sass_binary") +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -8,7 +8,7 @@ ng_module( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), - assets = [":dialog-container.css"] + glob(["**/*.html"]), + assets = glob(["**/*.html"]), deps = [ "//src:dev_mode_types", "//src/cdk/a11y", @@ -23,11 +23,6 @@ ng_module( ], ) -sass_binary( - name = "dialog_container_scss", - src = "dialog-container.scss", -) - ng_test_library( name = "unit_test_sources", srcs = glob( diff --git a/src/cdk-experimental/dialog/dialog-config.ts b/src/cdk-experimental/dialog/dialog-config.ts index 1218a8ff18d6..d985e37db01a 100644 --- a/src/cdk-experimental/dialog/dialog-config.ts +++ b/src/cdk-experimental/dialog/dialog-config.ts @@ -5,29 +5,26 @@ * 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 {Injector, ViewContainerRef} from '@angular/core'; + +import { + ViewContainerRef, + ComponentFactoryResolver, + Injector, + StaticProvider, + Type, +} from '@angular/core'; import {Direction} from '@angular/cdk/bidi'; -import {ComponentType} from '@angular/cdk/overlay'; -import {CdkDialogContainer} from './dialog-container'; +import {PositionStrategy, ScrollStrategy} from '@angular/cdk/overlay'; +import {BasePortalOutlet} from '@angular/cdk/portal'; /** Options for where to set focus to automatically on dialog open */ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; -/** Valid ARIA roles for a dialog element. */ +/** Valid ARIA roles for a dialog. */ 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; - +/** Configuration for opening a modal dialog. */ +export class DialogConfig { /** * 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 @@ -42,57 +39,63 @@ export class DialogConfig { */ injector?: Injector; - /** The id of the dialog. */ + /** ID for the dialog. If omitted, a unique one will be generated. */ id?: string; - /** The ARIA role of the dialog. */ + /** The ARIA role of the dialog element. */ role?: DialogRole = 'dialog'; - /** Custom class(es) for the overlay panel. */ + /** Optional CSS class or classes applied to the overlay panel. */ panelClass?: string | string[] = ''; - /** Whether the dialog has a background. */ + /** Whether the dialog has a backdrop. */ hasBackdrop?: boolean = true; - /** Custom class(es) for the backdrop. */ - backdropClass?: string | undefined = ''; + /** Optional CSS class or classes applied to the overlay backdrop. */ + backdropClass?: string | string[] = ''; - /** Whether the dialog can be closed by user interaction. */ + /** Whether the dialog closes with the escape key or pointer events outside the panel element. */ disableClose?: boolean = false; - /** The width of the dialog. */ + /** Width of the dialog. */ width?: string = ''; - /** The height of the dialog. */ + /** Height of the dialog. */ height?: string = ''; - /** The minimum width of the dialog. */ - minWidth?: string | number = ''; + /** Min-width of the dialog. If a number is provided, assumes pixel units. */ + minWidth?: number | string; - /** The minimum height of the dialog. */ - minHeight?: string | number = ''; + /** Min-height of the dialog. If a number is provided, assumes pixel units. */ + minHeight?: number | string; - /** The maximum width of the dialog. */ - maxWidth?: string | number = '80vw'; + /** Max-width of the dialog. If a number is provided, assumes pixel units. Defaults to 80vw. */ + maxWidth?: number | string; - /** The maximum height of the dialog. */ - maxHeight?: string | number = ''; + /** Max-height of the dialog. If a number is provided, assumes pixel units. */ + maxHeight?: number | string; - /** The position of the dialog. */ - position?: DialogPosition; + /** Strategy to use when positioning the dialog. Defaults to centering it on the page. */ + positionStrategy?: PositionStrategy; - /** Data to be injected into the dialog content. */ + /** Data being injected into the child component. */ data?: D | null = null; - /** The layout direction for the dialog content. */ + /** Layout direction for the dialog's content. */ direction?: Direction; /** ID of the element that describes the dialog. */ ariaDescribedBy?: string | null = null; - /** Aria label to assign to the dialog element */ + /** ID of the element that labels the dialog. */ + ariaLabelledBy?: string | null = null; + + /** Dialog label applied via `aria-label` */ ariaLabel?: string | null = null; + /** Whether this a modal dialog. Used to set the `aria-modal` attribute. */ + ariaModal?: boolean = true; + /** * Where the dialog should focus on open. * @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or @@ -100,9 +103,51 @@ export class DialogConfig { */ autoFocus?: AutoFocusTarget | string | boolean = 'first-tabbable'; - /** Duration of the enter animation. Has to be a valid CSS value (e.g. 100ms). */ - enterAnimationDuration?: string = '225ms'; + /** + * Whether the dialog should restore focus to the + * previously-focused element upon closing. + */ + restoreFocus?: boolean = true; + + /** + * Scroll strategy to be used for the dialog. This determines how + * the dialog responds to scrolling underneath the panel element. + */ + scrollStrategy?: ScrollStrategy; - /** Duration of the exit animation. Has to be a valid CSS value (e.g. 50ms). */ - exitAnimationDuration?: string = '225ms'; + /** + * Whether the dialog should close when the user navigates backwards or forwards through browser + * history. This does not apply to navigation via anchor element unless using URL-hash based + * routing (`HashLocationStrategy` in the Angular router). + */ + closeOnNavigation?: boolean = true; + + /** Alternate `ComponentFactoryResolver` to use when resolving the associated component. */ + componentFactoryResolver?: ComponentFactoryResolver; + + /** + * Providers that will be exposed to the contents of the dialog. Can also + * be provided as a function in order to generate the providers lazily. + */ + providers?: + | StaticProvider[] + | ((dialogRef: R, config: DialogConfig, container: C) => StaticProvider[]); + + /** + * Component into which the dialog content will be rendered. Defaults to `CdkDialogContainer`. + * A configuration object can be passed in to customize the providers that will be exposed + * to the dialog container. + */ + container?: + | Type + | { + type: Type; + providers: (config: DialogConfig) => StaticProvider[]; + }; + + /** + * Context that will be passed to template-based dialogs. + * A function can be passed in to resolve the context lazily. + */ + templateContext?: Record | (() => Record); } diff --git a/src/cdk-experimental/dialog/dialog-container.scss b/src/cdk-experimental/dialog/dialog-container.scss deleted file mode 100644 index 8eb48baae9d5..000000000000 --- a/src/cdk-experimental/dialog/dialog-container.scss +++ /dev/null @@ -1,6 +0,0 @@ -cdk-dialog-container { - background: white; - border-radius: 5px; - display: block; - padding: 10px; -} diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 88916d34b1dc..0e62bc9624cf 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -7,17 +7,13 @@ */ import { - animate, - animateChild, - AnimationEvent, - group, - query, - state, - style, - transition, - trigger, -} from '@angular/animations'; -import {FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y'; + FocusMonitor, + FocusOrigin, + FocusTrap, + FocusTrapFactory, + InteractivityChecker, +} from '@angular/cdk/a11y'; +import {OverlayRef} from '@angular/cdk/overlay'; import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import { BasePortalOutlet, @@ -28,8 +24,8 @@ import { } from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { + AfterViewInit, ChangeDetectionStrategy, - ChangeDetectorRef, Component, ComponentRef, ElementRef, @@ -41,8 +37,6 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import {Subject} from 'rxjs'; -import {distinctUntilChanged} from 'rxjs/operators'; import {DialogConfig} from './dialog-config'; export function throwDialogContentAlreadyAttachedError() { @@ -56,131 +50,77 @@ export function throwDialogContentAlreadyAttachedError() { @Component({ selector: 'cdk-dialog-container', templateUrl: './dialog-container.html', - styleUrls: ['dialog-container.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: [ - trigger('dialog', [ - state('enter', style({opacity: 1})), - state('exit, void', style({opacity: 0})), - transition( - '* => enter', - group([ - animate('{{enterAnimationDuration}}'), - query('@*', animateChild(), {optional: true}), - ]), - ), - transition( - '* => exit, * => void', - group([ - animate('{{exitAnimationDuration}}'), - query('@*', animateChild(), {optional: true}), - ]), - ), - ]), - ], host: { - '[@dialog]': `{ - value: _state, - params: { - enterAnimationDuration: _config.enterAnimationDuration, - exitAnimationDuration: _config.exitAnimationDuration - } - }`, - '(@dialog.start)': '_onAnimationStart($event)', - '(@dialog.done)': '_animationDone.next($event)', + 'class': 'cdk-dialog-container', 'tabindex': '-1', + '[attr.id]': '_config.id || null', '[attr.role]': '_config.role', - 'aria-modal': 'true', - '[attr.aria-label]': '_config.ariaLabel || null', - '[attr.aria-describedby]': '_config.ariaDescribedBy', + '[attr.aria-modal]': '_config.ariaModal', + '[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy', + '[attr.aria-label]': '_config.ariaLabel', + '[attr.aria-describedby]': '_config.ariaDescribedBy || null', }, }) -export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { - private readonly _document: Document; +export class CdkDialogContainer + extends BasePortalOutlet + implements AfterViewInit, OnDestroy +{ + protected _document: Document; - /** 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 portal outlet inside of this container into which the dialog content will be loaded. */ + @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; /** The class that traps and manages focus within the dialog. */ - private _focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); - - /** The portal host inside of this container into which the dialog content will be loaded. */ - @ViewChild(CdkPortalOutlet, {static: true}) _portalHost: CdkPortalOutlet; + private _focusTrap: FocusTrap; - /** A subject emitting before the dialog enters the view. */ - readonly _beforeEnter = new Subject(); - - /** A subject emitting after the dialog enters the view. */ - readonly _afterEnter = new Subject(); - - /** A subject emitting before the dialog exits the view. */ - readonly _beforeExit = new Subject(); + /** Element that was focused before the dialog was opened. Save this to restore upon close. */ + private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null; - /** A subject emitting after the dialog exits the view. */ - readonly _afterExit = new Subject(); + /** + * Type of interaction that led to the dialog being closed. This is used to determine + * whether the focus style will be applied when returning focus to its original location + * after the dialog is closed. + */ + _closeInteractionType: FocusOrigin | null = null; - /** Stream of animation `done` events. */ - readonly _animationDone = new Subject(); + /** ID of the element that should be considered as the dialog's label. */ + _ariaLabelledBy: string | null; constructor( - private _elementRef: ElementRef, - private _focusTrapFactory: FocusTrapFactory, - private _changeDetectorRef: ChangeDetectorRef, - private readonly _interactivityChecker: InteractivityChecker, - private readonly _ngZone: NgZone, + protected _elementRef: ElementRef, + protected _focusTrapFactory: FocusTrapFactory, @Optional() @Inject(DOCUMENT) _document: any, - /** The dialog configuration. */ - public _config: DialogConfig, + @Inject(DialogConfig) readonly _config: C, + private _interactivityChecker: InteractivityChecker, + private _ngZone: NgZone, + private _overlayRef: OverlayRef, + private _focusMonitor?: FocusMonitor, ) { super(); - + this._ariaLabelledBy = this._config.ariaLabelledBy || null; this._document = _document; + } - // We use a Subject with a distinctUntilChanged, rather than a callback attached to .done, - // because some browsers fire the done event twice and we don't want to emit duplicate events. - // See: https://github.com/angular/angular/issues/24084 - this._animationDone - .pipe( - distinctUntilChanged((x, y) => { - return x.fromState === y.fromState && x.toState === y.toState; - }), - ) - .subscribe(event => { - // Emit lifecycle events based on animation `done` callback. - if (event.toState === 'enter') { - this._autoFocus(); - this._afterEnter.next(); - this._afterEnter.complete(); - } - - if (event.fromState === 'enter' && (event.toState === 'void' || event.toState === 'exit')) { - this._returnFocusAfterDialog(); - this._afterExit.next(); - this._afterExit.complete(); - } - }); + ngAfterViewInit() { + this._initializeFocusTrap(); + this._handleBackdropClicks(); + this._captureInitialFocus(); } - /** Initializes the dialog container with the attached content. */ - _initializeWithAttachedContent() { - // 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(); + /** + * Can be used by child classes to customize the initial focus + * capturing behavior (e.g. if it's tied to an animation). + */ + protected _captureInitialFocus() { + this._trapFocus(); } - /** Destroy focus trap to place focus back to the element focused before the dialog opened. */ ngOnDestroy() { - this._focusTrap.destroy(); - this._animationDone.complete(); + this._restoreFocus(); } /** @@ -188,23 +128,23 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { * @param portal Portal to be attached as the dialog content. */ attachComponentPortal(portal: ComponentPortal): ComponentRef { - if (this._portalHost.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { + if (this._portalOutlet.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { throwDialogContentAlreadyAttachedError(); } - return this._portalHost.attachComponentPortal(portal); + return this._portalOutlet.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() && (typeof ngDevMode === 'undefined' || ngDevMode)) { + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this._portalOutlet.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { throwDialogContentAlreadyAttachedError(); } - return this._portalHost.attachTemplatePortal(portal); + return this._portalOutlet.attachTemplatePortal(portal); } /** @@ -214,49 +154,13 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { * @breaking-change 10.0.0 */ override attachDomPortal = (portal: DomPortal) => { - if (this._portalHost.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { + if (this._portalOutlet.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { throwDialogContentAlreadyAttachedError(); } - return this._portalHost.attachDomPortal(portal); + return this._portalOutlet.attachDomPortal(portal); }; - /** Emit lifecycle events based on animation `start` callback. */ - _onAnimationStart(event: AnimationEvent) { - if (event.toState === 'enter') { - this._beforeEnter.next(); - this._beforeEnter.complete(); - } - if (event.fromState === 'enter' && (event.toState === 'void' || event.toState === 'exit')) { - this._beforeExit.next(); - this._beforeExit.complete(); - } - } - - /** 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 = _getFocusedElementPierceShadowDom(); - } - } - - /** 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(); - } - } - /** * Focuses the provided element. If the element is not focusable, it will add a tabIndex * attribute to forcefully focus it. The attribute is removed after focus is moved. @@ -294,12 +198,11 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { } /** - * Autofocus the element specified by the autoFocus field. When autoFocus is not 'dialog', if - * for some reason the element cannot be focused, the dialog container will be focused. + * Moves the focus inside the focus trap. When autoFocus is not set to 'dialog', if focus + * cannot be moved then focus will go to the dialog container. */ - private _autoFocus() { + protected _trapFocus() { const element = this._elementRef.nativeElement; - // 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 when setting focus when autoFocus isn't set to @@ -308,21 +211,22 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { switch (this._config.autoFocus) { case false: case 'dialog': - const activeElement = _getFocusedElementPierceShadowDom(); // Ensure that focus is on the dialog container. It's possible that a different // component tried to move focus while the open animation was running. See: // https://github.com/angular/components/issues/16215. Note that we only want to do this // if the focus isn't inside the dialog already, because it's possible that the consumer // turned off `autoFocus` in order to move focus themselves. - if (activeElement !== element && !element.contains(activeElement)) { + if (!this._containsFocus()) { element.focus(); } break; case true: case 'first-tabbable': - this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => { - if (!hasMovedFocus) { - element.focus(); + this._focusTrap.focusInitialElementWhenReady().then(focusedSuccessfully => { + // If we weren't able to find a focusable element in the dialog, then focus the dialog + // container instead. + if (!focusedSuccessfully) { + this._focusDialogContainer(); } }); break; @@ -335,11 +239,16 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { } } - /** Returns the focus to the element focused before the dialog was open. */ - private _returnFocusAfterDialog() { - const toFocus = this._elementFocusedBeforeDialogWasOpened; + /** Restores focus to the element that was focused before the dialog opened. */ + private _restoreFocus() { + const previousElement = this._elementFocusedBeforeDialogWasOpened; + // We need the extra check, because IE can set the `activeElement` to null in some cases. - if (toFocus && typeof toFocus.focus === 'function') { + if ( + this._config.restoreFocus && + previousElement && + typeof previousElement.focus === 'function' + ) { const activeElement = _getFocusedElementPierceShadowDom(); const element = this._elementRef.nativeElement; @@ -353,8 +262,54 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { activeElement === element || element.contains(activeElement) ) { - toFocus.focus(); + if (this._focusMonitor) { + this._focusMonitor.focusVia(previousElement, this._closeInteractionType); + this._closeInteractionType = null; + } else { + previousElement.focus(); + } } } + + if (this._focusTrap) { + this._focusTrap.destroy(); + } + } + + /** 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(); + } + } + + /** Returns whether focus is inside the dialog. */ + private _containsFocus() { + const element = this._elementRef.nativeElement; + const activeElement = _getFocusedElementPierceShadowDom(); + return element === activeElement || element.contains(activeElement); + } + + /** Sets up the focus trap. */ + private _initializeFocusTrap() { + this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); + + // Save the previously focused element. This element will be re-focused + // when the dialog closes. + if (this._document) { + this._elementFocusedBeforeDialogWasOpened = _getFocusedElementPierceShadowDom(); + } + } + + /** Sets up the listener that handles clicks on the dialog backdrop. */ + private _handleBackdropClicks() { + // Clicking on the backdrop will move focus out of dialog. + // Recapture it if closing via the backdrop is disabled. + this._overlayRef.backdropClick().subscribe(() => { + if (this._config.disableClose && !this._containsFocus()) { + this._trapFocus(); + } + }); } } diff --git a/src/cdk-experimental/dialog/dialog-injectors.ts b/src/cdk-experimental/dialog/dialog-injectors.ts index 198e65feb350..74dc46e1bebe 100644 --- a/src/cdk-experimental/dialog/dialog-injectors.ts +++ b/src/cdk-experimental/dialog/dialog-injectors.ts @@ -7,9 +7,7 @@ */ import {InjectionToken} from '@angular/core'; -import {ComponentType, Overlay, ScrollStrategy} from '@angular/cdk/overlay'; -import {DialogRef} from './dialog-ref'; -import {CdkDialogContainer} from './dialog-container'; +import {Overlay, ScrollStrategy} from '@angular/cdk/overlay'; import {DialogConfig} from './dialog-config'; /** Injection token for the Dialog's ScrollStrategy. */ @@ -20,27 +18,17 @@ export const DIALOG_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( /** 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', -); +/** Injection token that can be used to provide default options for the dialog module. */ +export const DEFAULT_DIALOG_CONFIG = new InjectionToken('DefaultDialogConfig'); /** @docs-private */ -export function MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY( - overlay: Overlay, -): () => ScrollStrategy { +export function DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay): () => ScrollStrategy { return () => overlay.scrollStrategies.block(); } /** @docs-private */ -export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = { +export const DIALOG_SCROLL_STRATEGY_PROVIDER = { provide: DIALOG_SCROLL_STRATEGY, deps: [Overlay], - useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, + useFactory: DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, }; diff --git a/src/cdk-experimental/dialog/dialog-module.ts b/src/cdk-experimental/dialog/dialog-module.ts index 22bccdc6c8c0..6822ef5d5d57 100644 --- a/src/cdk-experimental/dialog/dialog-module.ts +++ b/src/cdk-experimental/dialog/dialog-module.ts @@ -12,14 +12,7 @@ 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'; +import {DIALOG_SCROLL_STRATEGY_PROVIDER} from './dialog-injectors'; @NgModule({ imports: [OverlayModule, PortalModule, A11yModule], @@ -30,12 +23,6 @@ import { 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}, - ], + providers: [Dialog, DIALOG_SCROLL_STRATEGY_PROVIDER], }) export class DialogModule {} diff --git a/src/cdk-experimental/dialog/dialog-ref.ts b/src/cdk-experimental/dialog/dialog-ref.ts index 853c12c67766..e3f57f6efd3e 100644 --- a/src/cdk-experimental/dialog/dialog-ref.ts +++ b/src/cdk-experimental/dialog/dialog-ref.ts @@ -6,144 +6,116 @@ * found in the LICENSE file at https://angular.io/license */ -import {OverlayRef, GlobalPositionStrategy, OverlaySizeConfig} from '@angular/cdk/overlay'; +import {OverlayRef} from '@angular/cdk/overlay'; import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes'; -import {Observable} from 'rxjs'; -import {map, filter} from 'rxjs/operators'; -import {DialogPosition} from './dialog-config'; -import {CdkDialogContainer} from './dialog-container'; - -/** Unique id for the created dialog. */ -let uniqueId = 0; +import {Observable, Subject} from 'rxjs'; +import {DialogConfig} from './dialog-config'; +import {FocusOrigin} from '@angular/cdk/a11y'; +import {BasePortalOutlet} from '@angular/cdk/portal'; + +/** Additional options that can be passed in when closing a dialog. */ +export interface DialogCloseOptions { + /** Focus original to use when restoring focus. */ + focusOrigin?: FocusOrigin; +} /** * Reference to a dialog opened via the Dialog service. */ -export class DialogRef { - /** The instance of the component in the dialog. */ - componentInstance: T; +export class DialogRef { + /** + * Instance of component opened into the dialog. Will be + * null when the dialog is opened using a `TemplateRef`. + */ + componentInstance: C | null; + + /** Instance of the container that is rendering out the dialog content. */ + containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin}; /** Whether the user is allowed to close the dialog. */ disableClose: boolean | undefined; - /** Result to be passed to afterClosed. */ - private _result: R | undefined; + /** Emits when the dialog has been closed. */ + readonly closed: Observable = new Subject(); - 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(); - } - }); - } + /** Emits when the backdrop of the dialog is clicked. */ + readonly backdropClick: Observable; - this.beforeClosed().subscribe(() => { - this._overlayRef.detachBackdrop(); - }); + /** Emits when on keyboard events within the dialog. */ + readonly keydownEvents: Observable; - this.afterClosed().subscribe(() => { - this._overlayRef.detach(); - this._overlayRef.dispose(); - this.componentInstance = null!; - }); + /** Emits on pointer events that happen outside of the dialog. */ + readonly outsidePointerEvents: Observable; - // Close when escape keydown event occurs - _overlayRef - .keydownEvents() - .pipe( - filter(event => { - return event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event); - }), - ) - .subscribe(event => { + /** Unique ID for the dialog. */ + readonly id: string; + + constructor( + readonly overlayRef: OverlayRef, + readonly config: DialogConfig, BasePortalOutlet>, + ) { + this.disableClose = config.disableClose; + this.backdropClick = overlayRef.backdropClick(); + this.keydownEvents = overlayRef.keydownEvents(); + this.outsidePointerEvents = overlayRef.outsidePointerEvents(); + this.id = config.id!; // By the time the dialog is created we are guaranteed to have an ID. + + this.keydownEvents.subscribe(event => { + if (event.keyCode === ESCAPE && !this.disableClose && !hasModifierKey(event)) { event.preventDefault(); - this.close(); - }); - } + this.close(undefined, {focusOrigin: 'keyboard'}); + } + }); - /** Gets an observable that emits when the overlay's backdrop has been clicked. */ - backdropClick(): Observable { - return this._overlayRef.backdropClick(); + this.backdropClick.subscribe(() => { + if (!this.disableClose) { + this.close(undefined, {focusOrigin: 'mouse'}); + } + }); } /** * Close the dialog. - * @param dialogResult Optional result to return to the dialog opener. + * @param result Optional result to return to the dialog opener. + * @param options Additional options to customize the closing behavior. */ - 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(); + close(result?: R, options?: DialogCloseOptions): void { + if (this.containerInstance) { + const closedSubject = this.closed as Subject; + this.containerInstance._closeInteractionType = options?.focusOrigin || 'program'; + this.overlayRef.dispose(); + closedSubject.next(result); + closedSubject.complete(); + this.componentInstance = this.containerInstance = null!; } - - 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 position. */ + updatePosition(): this { + this.overlayRef.updatePosition(); + return this; } /** - * Updates the dialog's width and height, defined, min and max. - * @param size New size for the overlay. + * Updates the dialog's width and height. + * @param width New width of the dialog. + * @param height New height of the dialog. */ - updateSize(size: OverlaySizeConfig): this { - this._overlayRef.updateSize(size); - this._overlayRef.updatePosition(); + updateSize(width: string = '', height: string = ''): this { + this.overlayRef.updateSize({width, height}); + 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. */ - beforeOpened(): Observable { - return this._containerInstance._beforeEnter; - } - - /** Gets an observable that emits when dialog is finished opening. */ - afterOpened(): Observable { - return this._containerInstance._afterEnter; - } - - /** Gets an observable that emits when dialog begins closing. */ - beforeClosed(): Observable { - return this._containerInstance._beforeExit.pipe(map(() => this._result)); + /** Add a CSS class or an array of classes to the overlay pane. */ + addPanelClass(classes: string | string[]): this { + this.overlayRef.addPanelClass(classes); + return this; } - /** Gets an observable that emits when dialog is finished closing. */ - afterClosed(): Observable { - return this._containerInstance._afterExit.pipe(map(() => this._result)); + /** Remove a CSS class or an array of classes from the overlay pane. */ + removePanelClass(classes: string | string[]): this { + this.overlayRef.removePanelClass(classes); + return this; } } diff --git a/src/cdk-experimental/dialog/dialog.spec.ts b/src/cdk-experimental/dialog/dialog.spec.ts index ca98a1335e5f..4ebab1617509 100644 --- a/src/cdk-experimental/dialog/dialog.spec.ts +++ b/src/cdk-experimental/dialog/dialog.spec.ts @@ -2,7 +2,6 @@ import { ComponentFixture, fakeAsync, flushMicrotasks, - inject, TestBed, tick, flush, @@ -19,12 +18,10 @@ import { ViewEncapsulation, } 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 {Overlay, OverlayContainer} from '@angular/cdk/overlay'; import {A, ESCAPE} from '@angular/cdk/keycodes'; import {_supportsShadowDom} from '@angular/cdk/platform'; import { @@ -41,10 +38,11 @@ describe('Dialog', () => { let testViewContainerRef: ViewContainerRef; let viewContainerFixture: ComponentFixture; let mockLocation: SpyLocation; + let overlay: Overlay; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [DialogModule, NoopAnimationsModule], + imports: [DialogModule], declarations: [ ComponentWithChildViewContainer, ComponentWithTemplateRef, @@ -58,26 +56,19 @@ describe('Dialog', () => { }); TestBed.compileComponents(); - })); - beforeEach(inject( - [Dialog, Location, OverlayContainer], - (d: Dialog, l: Location, o: OverlayContainer) => { - dialog = d; - mockLocation = l as SpyLocation; - overlayContainerElement = o.getContainerElement(); - }, - )); + dialog = TestBed.inject(Dialog); + mockLocation = TestBed.inject(Location) as SpyLocation; + overlay = TestBed.inject(Overlay); + overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement(); - beforeEach(() => { viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer); - viewContainerFixture.detectChanges(); testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer; - }); + })); it('should open a dialog with a component', () => { - let dialogRef = dialog.openFromComponent(PizzaMsg, { + let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, }); @@ -85,7 +76,7 @@ describe('Dialog', () => { expect(overlayContainerElement.textContent).toContain('Pizza'); expect(dialogRef.componentInstance instanceof PizzaMsg).toBe(true); - expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + expect(dialogRef.componentInstance!.dialogRef).toBe(dialogRef); viewContainerFixture.detectChanges(); viewContainerFixture.detectChanges(); @@ -101,7 +92,7 @@ describe('Dialog', () => { const data = {value: 'Knees'}; - let dialogRef = dialog.openFromTemplate(templateRefFixture.componentInstance.templateRef, { + let dialogRef = dialog.open(templateRefFixture.componentInstance.templateRef, { data, }); @@ -119,44 +110,29 @@ describe('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.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.openFromComponent(PizzaMsg, { + let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, }); viewContainerFixture.detectChanges(); - let dialogInjector = dialogRef.componentInstance.dialogInjector as Injector; + let dialogInjector = dialogRef.componentInstance!.dialogInjector as Injector; - expect(dialogRef.componentInstance.dialogRef).toBe(dialogRef); + 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); + 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); + expect(dialogRef.componentInstance!.dialogRef).toBe(dialogRef); viewContainerFixture.detectChanges(); let dialogContainerElement = overlayContainerElement.querySelector('cdk-dialog-container')!; @@ -164,7 +140,7 @@ describe('Dialog', () => { }); it('should apply the configured role to the dialog element', () => { - dialog.openFromComponent(PizzaMsg, {role: 'alertdialog'}); + dialog.open(PizzaMsg, {role: 'alertdialog'}); viewContainerFixture.detectChanges(); @@ -173,7 +149,7 @@ describe('Dialog', () => { }); it('should apply the specified `aria-describedby`', () => { - dialog.openFromComponent(PizzaMsg, {ariaDescribedBy: 'description-element'}); + dialog.open(PizzaMsg, {ariaDescribedBy: 'description-element'}); viewContainerFixture.detectChanges(); @@ -182,11 +158,11 @@ describe('Dialog', () => { }); it('should close a dialog and get back a result', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); let afterCloseCallback = jasmine.createSpy('afterClose callback'); viewContainerFixture.detectChanges(); - dialogRef.afterClosed().subscribe(afterCloseCallback); + dialogRef.closed.subscribe(afterCloseCallback); dialogRef.close('Charmander'); viewContainerFixture.detectChanges(); flush(); @@ -196,11 +172,11 @@ describe('Dialog', () => { })); it('should only emit the afterCloseEvent once when closed', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); let afterCloseCallback = jasmine.createSpy('afterClose callback'); viewContainerFixture.detectChanges(); - dialogRef.afterClosed().subscribe(afterCloseCallback); + dialogRef.closed.subscribe(afterCloseCallback); dialogRef.close(); viewContainerFixture.detectChanges(); flush(); @@ -208,28 +184,8 @@ describe('Dialog', () => { expect(afterCloseCallback).toHaveBeenCalledTimes(1); })); - it('should close a dialog and get back a result before it is closed', fakeAsync(() => { - const dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); - viewContainerFixture.detectChanges(); - - // beforeClose should emit before dialog container is destroyed - const beforeCloseHandler = jasmine.createSpy('beforeClose callback').and.callFake(() => { - expect(overlayContainerElement.querySelector('cdk-dialog-container')) - .not.withContext('dialog container exists when beforeClose is called') - .toBeNull(); - }); - - dialogRef.beforeClosed().subscribe(beforeCloseHandler); - dialogRef.close('Bulbasaur'); - viewContainerFixture.detectChanges(); - flush(); - - expect(beforeCloseHandler).toHaveBeenCalledWith('Bulbasaur'); - expect(overlayContainerElement.querySelector('cdk-dialog-container')).toBeNull(); - })); - it('should close a dialog via the escape key', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); viewContainerFixture.detectChanges(); const event = dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); @@ -241,7 +197,7 @@ describe('Dialog', () => { })); it('should not close a dialog via the escape key if a modifier is pressed', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); viewContainerFixture.detectChanges(); const event = createKeyboardEvent('keydown', ESCAPE, undefined, {alt: true}); @@ -258,7 +214,7 @@ describe('Dialog', () => { onPushFixture.detectChanges(); - const dialogRef = dialog.openFromComponent(PizzaMsg, { + const dialogRef = dialog.open(PizzaMsg, { viewContainerRef: onPushFixture.componentInstance.viewContainerRef, }); @@ -281,7 +237,7 @@ describe('Dialog', () => { })); it('should close when clicking on the overlay backdrop', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); viewContainerFixture.detectChanges(); const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; @@ -294,29 +250,33 @@ describe('Dialog', () => { })); it('should emit the backdropClick stream when clicking on the overlay backdrop', fakeAsync(() => { - const dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + // Disable closing so the backdrop doesn't go away immediately. + disableClose: true, + }); const spy = jasmine.createSpy('backdropClick spy'); - dialogRef.backdropClick().subscribe(spy); + dialogRef.backdropClick.subscribe(spy); viewContainerFixture.detectChanges(); const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); - expect(spy).toHaveBeenCalledTimes(1); - viewContainerFixture.detectChanges(); - flush(); + expect(spy).toHaveBeenCalledTimes(1); // Additional clicks after the dialog has closed should not be emitted + dialogRef.disableClose = false; backdrop.click(); + viewContainerFixture.detectChanges(); 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 dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); const spy = jasmine.createSpy('keyboardEvent spy'); - dialogRef.keydownEvents().subscribe(spy); + dialogRef.keydownEvents.subscribe(spy); viewContainerFixture.detectChanges(); @@ -333,7 +293,7 @@ describe('Dialog', () => { it('should notify the observers if a dialog has been opened', () => { dialog.afterOpened.subscribe(ref => { expect( - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, }), ).toBe(ref); @@ -341,8 +301,8 @@ describe('Dialog', () => { }); 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, { + const ref1 = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + const ref2 = dialog.open(ContentElementDialog, { viewContainerRef: testViewContainerRef, }); const spy = jasmine.createSpy('afterAllClosed spy'); @@ -372,7 +332,7 @@ describe('Dialog', () => { }); it('should override the width of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { width: '500px', }); @@ -384,7 +344,7 @@ describe('Dialog', () => { }); it('should override the height of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { height: '100px', }); @@ -396,7 +356,7 @@ describe('Dialog', () => { }); it('should override the min-width of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { minWidth: '500px', }); @@ -408,15 +368,13 @@ describe('Dialog', () => { }); it('should override the max-width of the overlay pane', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg); + let dialogRef = dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; - expect(overlayPane.style.maxWidth) - .withContext('Expected dialog to set a default max-width on overlay pane') - .toBe('80vw'); + expect(overlayPane.style.maxWidth).toBeFalsy(); dialogRef.close(); @@ -424,7 +382,7 @@ describe('Dialog', () => { viewContainerFixture.detectChanges(); flushMicrotasks(); - dialogRef = dialog.openFromComponent(PizzaMsg, { + dialogRef = dialog.open(PizzaMsg, { maxWidth: '100px', }); @@ -437,7 +395,7 @@ describe('Dialog', () => { })); it('should override the min-height of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { minHeight: '300px', }); @@ -449,7 +407,7 @@ describe('Dialog', () => { }); it('should override the max-height of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { maxHeight: '100px', }); @@ -460,11 +418,9 @@ describe('Dialog', () => { expect(overlayPane.style.maxHeight).toBe('100px'); }); - it('should override the top offset of the overlay pane', () => { - dialog.openFromComponent(PizzaMsg, { - position: { - top: '100px', - }, + it('should be able to customize the position strategy', () => { + dialog.open(PizzaMsg, { + positionStrategy: overlay.position().global().top('100px'), }); viewContainerFixture.detectChanges(); @@ -474,68 +430,8 @@ describe('Dialog', () => { expect(overlayPane.style.marginTop).toBe('100px'); }); - it('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 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 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'}); + let dialogRef = dialog.open(PizzaMsg, {width: '100px'}); viewContainerFixture.detectChanges(); @@ -543,13 +439,13 @@ describe('Dialog', () => { expect(overlayPane.style.width).toBe('100px'); - dialogRef.updateSize({width: '200px'}); + dialogRef.updateSize('200px'); expect(overlayPane.style.width).toBe('200px'); }); it('should allow setting the layout direction', () => { - dialog.openFromComponent(PizzaMsg, {direction: 'rtl'}); + dialog.open(PizzaMsg, {direction: 'rtl'}); viewContainerFixture.detectChanges(); @@ -559,27 +455,27 @@ describe('Dialog', () => { }); it('should inject the correct layout direction in the component instance', () => { - const dialogRef = dialog.openFromComponent(PizzaMsg, {direction: 'rtl'}); + const dialogRef = dialog.open(PizzaMsg, {direction: 'rtl'}); viewContainerFixture.detectChanges(); - expect(dialogRef.componentInstance.directionality.value).toBe('rtl'); + 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.openFromComponent(PizzaMsg, {}); + const dialogRef = dialog.open(PizzaMsg, {}); viewContainerFixture.detectChanges(); - expect(dialogRef.componentInstance.directionality.value).toBe('ltr'); + expect(dialogRef.componentInstance!.directionality.value).toBe('ltr'); }); it('should close all of the dialogs', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(3); @@ -591,23 +487,10 @@ describe('Dialog', () => { 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.open(PizzaMsg); viewContainerFixture.detectChanges(); - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2); @@ -620,9 +503,9 @@ describe('Dialog', () => { })); it('should close all open dialogs when the location hash changes', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); - dialog.openFromComponent(PizzaMsg); + dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2); @@ -635,14 +518,14 @@ describe('Dialog', () => { })); it('should have the componentInstance available in the afterClosed callback', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg); + let dialogRef = dialog.open(PizzaMsg); let spy = jasmine.createSpy('afterClosed spy'); flushMicrotasks(); viewContainerFixture.detectChanges(); flushMicrotasks(); - dialogRef.afterClosed().subscribe(() => { + dialogRef.closed.subscribe(() => { spy(); }); @@ -657,8 +540,8 @@ describe('Dialog', () => { })); it('should close all open dialogs on destroy', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); - dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); viewContainerFixture.detectChanges(); expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(2); @@ -670,27 +553,18 @@ describe('Dialog', () => { expect(overlayContainerElement.querySelectorAll('cdk-dialog-container').length).toBe(0); })); - it('should complete the various lifecycle streams on destroy', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); - let beforeOpenedComplete = jasmine.createSpy('before opened complete spy'); - let afterOpenedComplete = jasmine.createSpy('after opened complete spy'); - let beforeClosedComplete = jasmine.createSpy('before closed complete spy'); - let afterClosedComplete = jasmine.createSpy('after closed complete spy'); + it('should complete the closed stream on destroy', fakeAsync(() => { + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let closedCompleteSpy = jasmine.createSpy('closed complete spy'); viewContainerFixture.detectChanges(); - dialogRef.beforeOpened().subscribe({complete: beforeOpenedComplete}); - dialogRef.afterOpened().subscribe({complete: afterOpenedComplete}); - dialogRef.beforeClosed().subscribe({complete: beforeClosedComplete}); - dialogRef.afterClosed().subscribe({complete: afterClosedComplete}); + dialogRef.closed.subscribe({complete: closedCompleteSpy}); dialogRef.close('Charmander'); viewContainerFixture.detectChanges(); flush(); - expect(beforeOpenedComplete).toHaveBeenCalled(); - expect(afterOpenedComplete).toHaveBeenCalled(); - expect(beforeClosedComplete).toHaveBeenCalled(); - expect(afterClosedComplete).toHaveBeenCalled(); + expect(closedCompleteSpy).toHaveBeenCalled(); })); describe('passing in data', () => { @@ -702,7 +576,7 @@ describe('Dialog', () => { }, }; - let instance = dialog.openFromComponent(DialogWithInjectedData, config).componentInstance; + let instance = dialog.open(DialogWithInjectedData, config).componentInstance!; expect(instance.data.stringParam).toBe(config.data.stringParam); expect(instance.data.dateParam).toBe(config.data.dateParam); @@ -710,15 +584,15 @@ describe('Dialog', () => { it('should default to null if no data is passed', () => { expect(() => { - let dialogRef = dialog.openFromComponent(DialogWithInjectedData); + let dialogRef = dialog.open(DialogWithInjectedData); viewContainerFixture.detectChanges(); - expect(dialogRef.componentInstance.data).toBeNull(); + 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); + let dialogRef = dialog.open(PizzaMsg); viewContainerFixture.detectChanges(); expect(dialogRef.componentInstance).toBeTruthy(); @@ -734,8 +608,8 @@ describe('Dialog', () => { })); it('should assign a unique id to each dialog', () => { - const one = dialog.openFromComponent(PizzaMsg); - const two = dialog.openFromComponent(PizzaMsg); + const one = dialog.open(PizzaMsg); + const two = dialog.open(PizzaMsg); expect(one.id).toBeTruthy(); expect(two.id).toBeTruthy(); @@ -743,23 +617,23 @@ describe('Dialog', () => { }); it('should allow for the id to be overwritten', () => { - const dialogRef = dialog.openFromComponent(PizzaMsg, {id: 'pizza'}); + 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.openFromComponent(PizzaMsg, {id: 'pizza'}); - expect(() => dialog.openFromComponent(PizzaMsg, {id: 'pizza'})).toThrowError(/must be unique/g); + 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.openFromComponent(PizzaMsg, {id: 'pizza'}); - expect(dialog.getById('pizza')).toBe(dialogRef); + const dialogRef = dialog.open(PizzaMsg, {id: 'pizza'}); + expect(dialog.getDialogById('pizza')).toBe(dialogRef); }); describe('disableClose option', () => { it('should prevent closing via clicks on the backdrop', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { disableClose: true, viewContainerRef: testViewContainerRef, }); @@ -775,7 +649,7 @@ describe('Dialog', () => { })); it('should prevent closing via the escape key', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { disableClose: true, viewContainerRef: testViewContainerRef, }); @@ -789,7 +663,7 @@ describe('Dialog', () => { })); it('should allow for the disableClose option to be updated while open', fakeAsync(() => { - let dialogRef = dialog.openFromComponent(PizzaMsg, { + let dialogRef = dialog.open(PizzaMsg, { disableClose: true, viewContainerRef: testViewContainerRef, }); @@ -813,7 +687,7 @@ describe('Dialog', () => { const templateRefFixture = TestBed.createComponent(ComponentWithTemplateRef); templateRefFixture.detectChanges(); - dialog.openFromTemplate(templateRefFixture.componentInstance.templateRef, { + dialog.open(templateRefFixture.componentInstance.templateRef, { disableClose: true, }); @@ -830,7 +704,7 @@ describe('Dialog', () => { describe('hasBackdrop option', () => { it('should have a backdrop', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { hasBackdrop: true, viewContainerRef: testViewContainerRef, }); @@ -841,7 +715,7 @@ describe('Dialog', () => { }); it('should not have a backdrop', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { hasBackdrop: false, viewContainerRef: testViewContainerRef, }); @@ -854,7 +728,7 @@ describe('Dialog', () => { describe('panelClass option', () => { it('should have custom panel class', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { panelClass: 'custom-panel-class', viewContainerRef: testViewContainerRef, }); @@ -867,7 +741,7 @@ describe('Dialog', () => { describe('backdropClass option', () => { it('should have default backdrop class', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { backdropClass: '', viewContainerRef: testViewContainerRef, }); @@ -878,7 +752,7 @@ describe('Dialog', () => { }); it('should have custom backdrop class', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { backdropClass: 'custom-backdrop-class', viewContainerRef: testViewContainerRef, }); @@ -895,7 +769,7 @@ describe('Dialog', () => { afterEach(() => overlayContainerElement.remove()); it('should focus the first tabbable element of the dialog on open (the default)', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, }); @@ -908,7 +782,7 @@ describe('Dialog', () => { })); it('should focus the dialog element on open', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, autoFocus: 'dialog', }); @@ -926,7 +800,7 @@ describe('Dialog', () => { })); it('should focus the first header element on open', fakeAsync(() => { - dialog.openFromComponent(ContentElementDialog, { + dialog.open(ContentElementDialog, { viewContainerRef: testViewContainerRef, autoFocus: 'first-heading', }); @@ -944,7 +818,7 @@ describe('Dialog', () => { })); it('should focus the first element that matches the css selector from autoFocus on open', fakeAsync(() => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, autoFocus: 'p', }); @@ -968,7 +842,7 @@ describe('Dialog', () => { document.body.appendChild(button); button.focus(); - let dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); flushMicrotasks(); viewContainerFixture.detectChanges(); @@ -980,11 +854,6 @@ describe('Dialog', () => { ); 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(); @@ -1008,7 +877,7 @@ describe('Dialog', () => { button.focus(); - const dialogRef = dialog.openFromComponent(PizzaMsg); + const dialogRef = dialog.open(PizzaMsg); flushMicrotasks(); fixture.detectChanges(); flushMicrotasks(); @@ -1022,50 +891,6 @@ describe('Dialog', () => { expect(spy).toHaveBeenCalled(); })); - 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.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.', - ); - - // Start the closing sequence and move focus out of dialog. - dialogRef.close(); - otherButton.focus(); - - expect(document.activeElement!.id) - .withContext('Expected focus to be on the alternate button.') - .toBe('other-button'); - - flushMicrotasks(); - viewContainerFixture.detectChanges(); - flush(); - - expect(document.activeElement!.id) - .withContext('Expected focus to stay on the alternate button.') - .toBe('other-button'); - - button.remove(); - otherButton.remove(); - })); - 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'); @@ -1078,12 +903,12 @@ describe('Dialog', () => { document.body.appendChild(input); button.focus(); - let dialogRef = dialog.openFromComponent(PizzaMsg, {viewContainerRef: testViewContainerRef}); + let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); tick(500); viewContainerFixture.detectChanges(); - dialogRef.afterClosed().subscribe(() => input.focus()); + dialogRef.closed.subscribe(() => input.focus()); dialogRef.close(); tick(500); @@ -1100,7 +925,7 @@ describe('Dialog', () => { })); it('should move focus to the container if there are no focusable elements in the dialog', fakeAsync(() => { - dialog.openFromComponent(DialogWithoutFocusableElements); + dialog.open(DialogWithoutFocusableElements); viewContainerFixture.detectChanges(); flushMicrotasks(); @@ -1113,7 +938,7 @@ describe('Dialog', () => { describe('aria-label', () => { it('should be able to set a custom aria-label', () => { - dialog.openFromComponent(PizzaMsg, { + dialog.open(PizzaMsg, { ariaLabel: 'Hello there', viewContainerRef: testViewContainerRef, }); @@ -1124,7 +949,7 @@ describe('Dialog', () => { }); it('should not set the aria-labelledby automatically if it has an aria-label', fakeAsync(() => { - dialog.openFromComponent(ContentElementDialog, { + dialog.open(ContentElementDialog, { ariaLabel: 'Hello there', viewContainerRef: testViewContainerRef, }); @@ -1146,7 +971,7 @@ describe('Dialog with a parent Dialog', () => { beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [DialogModule, NoopAnimationsModule], + imports: [DialogModule], declarations: [ComponentThatProvidesMatDialog], providers: [ { @@ -1161,11 +986,7 @@ describe('Dialog with a parent Dialog', () => { }); TestBed.compileComponents(); - })); - - beforeEach(inject([Dialog], (d: Dialog) => { - parentDialog = d; - + parentDialog = TestBed.inject(Dialog); fixture = TestBed.createComponent(ComponentThatProvidesMatDialog); childDialog = fixture.componentInstance.dialog; fixture.detectChanges(); @@ -1176,7 +997,7 @@ describe('Dialog with a parent Dialog', () => { }); it('should close dialogs opened by a parent when calling closeAll on a child Dialog', fakeAsync(() => { - parentDialog.openFromComponent(PizzaMsg); + parentDialog.open(PizzaMsg); fixture.detectChanges(); flush(); @@ -1194,7 +1015,7 @@ describe('Dialog with a parent Dialog', () => { })); it('should close dialogs opened by a child when calling closeAll on a parent Dialog', fakeAsync(() => { - childDialog.openFromComponent(PizzaMsg); + childDialog.open(PizzaMsg); fixture.detectChanges(); expect(overlayContainerElement.textContent) @@ -1211,7 +1032,7 @@ describe('Dialog with a parent Dialog', () => { })); it('should not close the parent dialogs, when a child is destroyed', fakeAsync(() => { - parentDialog.openFromComponent(PizzaMsg); + parentDialog.open(PizzaMsg); fixture.detectChanges(); flush(); @@ -1229,7 +1050,7 @@ describe('Dialog with a parent Dialog', () => { })); it('should close the top dialog via the escape key', fakeAsync(() => { - childDialog.openFromComponent(PizzaMsg); + childDialog.open(PizzaMsg); fixture.detectChanges(); dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); diff --git a/src/cdk-experimental/dialog/dialog.ts b/src/cdk-experimental/dialog/dialog.ts index cc9fc8cf0fc8..79da1243ba17 100644 --- a/src/cdk-experimental/dialog/dialog.ts +++ b/src/cdk-experimental/dialog/dialog.ts @@ -8,177 +8,163 @@ import { TemplateRef, - SkipSelf, - Optional, Injectable, Injector, - Inject, - ComponentRef, OnDestroy, Type, StaticProvider, InjectFlags, + Inject, + Optional, + SkipSelf, } from '@angular/core'; -import {ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; +import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; import {of as observableOf, Observable, Subject, defer} from 'rxjs'; import {DialogRef} from './dialog-ref'; -import {Location} from '@angular/common'; import {DialogConfig} from './dialog-config'; import {Directionality} from '@angular/cdk/bidi'; -import {CdkDialogContainer} from './dialog-container'; import { ComponentType, Overlay, OverlayRef, OverlayConfig, ScrollStrategy, + OverlayContainer, } from '@angular/cdk/overlay'; import {startWith} from 'rxjs/operators'; -import { - DIALOG_SCROLL_STRATEGY, - DIALOG_DATA, - DIALOG_REF, - DIALOG_CONTAINER, - DIALOG_CONFIG, -} from './dialog-injectors'; +import {DEFAULT_DIALOG_CONFIG, DIALOG_DATA, DIALOG_SCROLL_STRATEGY} from './dialog-injectors'; +import {CdkDialogContainer} from './dialog-container'; + +/** Unique id for the created dialog. */ +let uniqueId = 0; -/** - * Service to open modal dialogs. - */ @Injectable() export class Dialog implements OnDestroy { + private _openDialogsAtThisLevel: DialogRef[] = []; + private readonly _afterAllClosedAtThisLevel = new Subject(); + private readonly _afterOpenedAtThisLevel = new Subject(); + private _ariaHiddenElements = new Map(); private _scrollStrategy: () => ScrollStrategy; - /** Stream that emits when all dialogs are closed. */ - _getAfterAllClosed(): Observable { - return this._parentDialog ? this._parentDialog.afterAllClosed : this._afterAllClosedBase; + /** Keeps track of the currently-open dialogs. */ + get openDialogs(): DialogRef[] { + return this._parentDialog ? this._parentDialog.openDialogs : this._openDialogsAtThisLevel; } - readonly _afterAllClosedBase = new Subject(); - // TODO(jelbourn): tighten the type on the right-hand side of this expression. - afterAllClosed: Observable = defer(() => + /** Stream that emits when a dialog has been opened. */ + get afterOpened(): Subject> { + return this._parentDialog ? this._parentDialog.afterOpened : this._afterOpenedAtThisLevel; + } + + /** + * Stream that emits when all open dialog have finished closing. + * Will emit on subscribe if there are no open dialogs to begin with. + */ + readonly afterAllClosed: Observable = defer(() => this.openDialogs.length ? this._getAfterAllClosed() : this._getAfterAllClosed().pipe(startWith(undefined)), ); - /** Stream that emits when a dialog is opened. */ - get afterOpened(): Subject> { - return this._parentDialog ? this._parentDialog.afterOpened : this._afterOpened; - } - readonly _afterOpened = 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: Type>, - // TODO(crisbeto): the `any` here can be replaced - // with the proper type once we start using Ivy. - @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() @Inject(DEFAULT_DIALOG_CONFIG) private _defaultOptions: DialogConfig, @Optional() @SkipSelf() private _parentDialog: Dialog, - @Optional() location: Location, + private _overlayContainer: OverlayContainer, + @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, ) { - // 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._scrollStrategy = scrollStrategy; } - /** 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 modal dialog containing the given component. + * @param component Type of the component to load into the dialog. + * @param config Extra configuration options. + * @returns Reference to the newly-opened dialog. + */ + open( + component: ComponentType, + config?: DialogConfig>, + ): DialogRef; - /** Opens a dialog from a component. */ - openFromComponent(component: ComponentType, config?: DialogConfig): DialogRef { - config = this._applyConfigDefaults(config); + /** + * Opens a modal dialog containing the given template. + * @param template TemplateRef to instantiate as the dialog content. + * @param config Extra configuration options. + * @returns Reference to the newly-opened dialog. + */ + open( + template: TemplateRef, + config?: DialogConfig>, + ): DialogRef; + + open( + componentOrTemplateRef: ComponentType | TemplateRef, + config?: DialogConfig>, + ): DialogRef; + + open( + componentOrTemplateRef: ComponentType | TemplateRef, + config?: DialogConfig>, + ): DialogRef { + const defaults = (this._defaultOptions || new DialogConfig()) as DialogConfig< + D, + DialogRef + >; + config = {...defaults, ...config}; + config.id = config.id || `cdk-dialog-${uniqueId++}`; - if (config.id && this.getById(config.id) && (typeof ngDevMode === 'undefined' || ngDevMode)) { + if ( + config.id && + this.getDialogById(config.id) && + (typeof ngDevMode === 'undefined' || ngDevMode) + ) { 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); - dialogContainer._initializeWithAttachedContent(); - - return dialogRef; - } + const overlayConfig = this._getOverlayConfig(config); + const overlayRef = this._overlay.create(overlayConfig); + const dialogRef = new DialogRef(overlayRef, config); + const dialogContainer = this._attachContainer(overlayRef, dialogRef, config); - /** Opens a dialog from a template. */ - openFromTemplate(template: TemplateRef, config?: DialogConfig): DialogRef { - config = this._applyConfigDefaults(config); + dialogRef.containerInstance = dialogContainer; + this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); - if (config.id && this.getById(config.id) && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw Error(`Dialog with id "${config.id}" exists already. The dialog id must be unique.`); + // If this is the first dialog that we're opening, hide all the non-overlay content. + if (!this.openDialogs.length) { + this._hideNonDialogContentFromAssistiveTechnology(); } - const overlayRef = this._createOverlay(config); - const dialogContainer = this._attachDialogContainer(overlayRef, config); - const dialogRef = this._attachDialogContentForTemplate( - template, - dialogContainer, - overlayRef, - config, - ); - - this._registerDialogRef(dialogRef); - dialogContainer._initializeWithAttachedContent(); + this.openDialogs.push(dialogRef); + dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef)); + this.afterOpened.next(dialogRef); return dialogRef; } - ngOnDestroy() { - // Only close all the dialogs at this level. - this._openDialogs.forEach(ref => ref.close()); + /** + * Closes all of the currently-open dialogs. + */ + closeAll(): void { + this._closeDialogs(this.openDialogs); } /** - * Forwards emitting events for when dialogs are opened and all dialogs are closed. + * Finds an open dialog by its id. + * @param id ID to use when looking up the dialog. */ - private _registerDialogRef(dialogRef: DialogRef): void { - this.openDialogs.push(dialogRef); - - const dialogOpenSub = dialogRef.afterOpened().subscribe(() => { - this.afterOpened.next(dialogRef); - dialogOpenSub.unsubscribe(); - }); - - const dialogCloseSub = dialogRef.afterClosed().subscribe(() => { - let dialogIndex = this._openDialogs.indexOf(dialogRef); - - if (dialogIndex > -1) { - this._openDialogs.splice(dialogIndex, 1); - } + getDialogById(id: string): DialogRef | undefined { + return this.openDialogs.find(dialog => dialog.id === id); + } - if (!this._openDialogs.length) { - this._afterAllClosedBase.next(); - dialogCloseSub.unsubscribe(); - } - }); + ngOnDestroy() { + // Only close the dialogs at this level on destroy + // since the parent service may still be active. + this._closeDialogs(this._openDialogsAtThisLevel); + this._afterAllClosedAtThisLevel.complete(); + this._afterOpenedAtThisLevel.complete(); } /** @@ -186,10 +172,12 @@ export class Dialog implements OnDestroy { * @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(), + private _getOverlayConfig(config: DialogConfig): OverlayConfig { + const state = new OverlayConfig({ + positionStrategy: + config.positionStrategy || + this._overlay.position().global().centerHorizontally().centerVertically(), + scrollStrategy: config.scrollStrategy || this._scrollStrategy(), panelClass: config.panelClass, hasBackdrop: config.hasBackdrop, direction: config.direction, @@ -197,107 +185,130 @@ export class Dialog implements OnDestroy { minHeight: config.minHeight, maxWidth: config.maxWidth, maxHeight: config.maxHeight, + width: config.width, + height: config.height, + disposeOnNavigation: config.closeOnNavigation, }); if (config.backdropClass) { - overlayConfig.backdropClass = config.backdropClass; + state.backdropClass = config.backdropClass; } - return this._overlay.create(overlayConfig); + + return state; } /** - * 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. */ - protected _attachDialogContainer(overlay: OverlayRef, config: DialogConfig): CdkDialogContainer { - const container = config.containerComponent || this._injector.get(DIALOG_CONTAINER); + private _attachContainer( + overlay: OverlayRef, + dialogRef: DialogRef, + config: DialogConfig>, + ): BasePortalOutlet { const userInjector = config.injector ?? config.viewContainerRef?.injector; - const injector = Injector.create({ - parent: userInjector || this._injector, - providers: [{provide: DialogConfig, useValue: config}], - }); - const containerPortal = new ComponentPortal(container, config.viewContainerRef, injector); - const containerRef: ComponentRef = overlay.attach(containerPortal); - containerRef.instance._config = config; + const providers: StaticProvider[] = [ + {provide: DialogConfig, useValue: config}, + {provide: DialogRef, useValue: dialogRef}, + {provide: OverlayRef, useValue: overlay}, + ]; + let containerType: Type; + + if (config.container) { + if (typeof config.container === 'function') { + containerType = config.container; + } else { + containerType = config.container.type; + providers.push(...config.container.providers(config)); + } + } else { + containerType = CdkDialogContainer; + } + + const containerPortal = new ComponentPortal( + containerType, + config.viewContainerRef, + Injector.create({parent: userInjector || this._injector, providers}), + config.componentFactoryResolver, + ); + 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 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. + * @param dialogRef Reference to the dialog being opened. + * @param dialogContainer Component that is going to wrap the dialog content. + * @param config Configuration used to open the dialog. */ - 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 = this._createDialogRef(overlayRef, dialogContainer, config); - const injector = this._createInjector(config, dialogRef, dialogContainer); - const contentRef = dialogContainer.attachComponentPortal( - new ComponentPortal(componentOrTemplateRef, undefined, injector), - ); - dialogRef.componentInstance = contentRef.instance; - return dialogRef; - } + private _attachDialogContent( + componentOrTemplateRef: ComponentType | TemplateRef, + dialogRef: DialogRef, + dialogContainer: BasePortalOutlet, + config: DialogConfig>, + ) { + const injector = this._createInjector(config, dialogRef, dialogContainer); + + if (componentOrTemplateRef instanceof TemplateRef) { + let context: any = {$implicit: config.data, dialogRef}; + + if (config.templateContext) { + context = { + ...context, + ...(typeof config.templateContext === 'function' + ? config.templateContext() + : config.templateContext), + }; + } - /** - * 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 = this._createDialogRef(overlayRef, dialogContainer, config); - dialogContainer.attachTemplatePortal( - new TemplatePortal(componentOrTemplateRef, null!, { - $implicit: config.data, - dialogRef, - }), - ); - return dialogRef; + dialogContainer.attachTemplatePortal( + new TemplatePortal(componentOrTemplateRef, null!, context, injector), + ); + } else { + const contentRef = dialogContainer.attachComponentPortal( + new ComponentPortal( + componentOrTemplateRef, + config.viewContainerRef, + injector, + config.componentFactoryResolver, + ), + ); + dialogRef.componentInstance = contentRef.instance; + } } /** * 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. + * @param dialogRef Reference to the dialog being opened. + * @param dialogContainer Component that is going to wrap the dialog content. * @returns The custom injector that can be used inside the dialog. */ - private _createInjector( - config: DialogConfig, - dialogRef: DialogRef, - dialogContainer: CdkDialogContainer, + private _createInjector( + config: DialogConfig>, + dialogRef: DialogRef, + dialogContainer: BasePortalOutlet, ): Injector { const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; const providers: StaticProvider[] = [ - {provide: this._injector.get(DIALOG_REF), useValue: dialogRef}, - {provide: this._injector.get(DIALOG_CONTAINER), useValue: dialogContainer}, {provide: DIALOG_DATA, useValue: config.data}, + {provide: DialogRef, useValue: dialogRef}, ]; + if (config.providers) { + if (typeof config.providers === 'function') { + providers.push(...config.providers(dialogRef, config, dialogContainer)); + } else { + providers.push(...config.providers); + } + } + if ( config.direction && (!userInjector || @@ -312,24 +323,72 @@ export class Dialog implements OnDestroy { return Injector.create({parent: userInjector || this._injector, providers}); } - /** Creates a new dialog ref. */ - private _createDialogRef( - overlayRef: OverlayRef, - dialogContainer: CdkDialogContainer, - config: DialogConfig, - ) { - const dialogRef = new this._dialogRefConstructor(overlayRef, dialogContainer, config.id); - dialogRef.disableClose = config.disableClose; - dialogRef.updateSize(config).updatePosition(config.position); - return dialogRef; - } - /** - * Expands the provided configuration object to include the default values for properties which - * are undefined. + * Removes a dialog from the array of open dialogs. + * @param dialogRef Dialog to be removed. */ - private _applyConfigDefaults(config?: DialogConfig): DialogConfig { - const dialogConfig = this._injector.get(DIALOG_CONFIG) as typeof DialogConfig; - return {...new dialogConfig(), ...config}; + private _removeOpenDialog(dialogRef: DialogRef) { + const index = this.openDialogs.indexOf(dialogRef); + + if (index > -1) { + this.openDialogs.splice(index, 1); + + // If all the dialogs were closed, remove/restore the `aria-hidden` + // to a the siblings and emit to the `afterAllClosed` stream. + if (!this.openDialogs.length) { + this._ariaHiddenElements.forEach((previousValue, element) => { + if (previousValue) { + element.setAttribute('aria-hidden', previousValue); + } else { + element.removeAttribute('aria-hidden'); + } + }); + + this._ariaHiddenElements.clear(); + this._getAfterAllClosed().next(); + } + } + } + + /** Hides all of the content that isn't an overlay from assistive technology. */ + private _hideNonDialogContentFromAssistiveTechnology() { + const overlayContainer = this._overlayContainer.getContainerElement(); + + // Ensure that the overlay container is attached to the DOM. + if (overlayContainer.parentElement) { + const siblings = overlayContainer.parentElement.children; + + for (let i = siblings.length - 1; i > -1; i--) { + const sibling = siblings[i]; + + if ( + sibling !== overlayContainer && + sibling.nodeName !== 'SCRIPT' && + sibling.nodeName !== 'STYLE' && + !sibling.hasAttribute('aria-live') + ) { + this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden')); + sibling.setAttribute('aria-hidden', 'true'); + } + } + } + } + + /** Closes all of the dialogs in an array. */ + private _closeDialogs(dialogs: DialogRef[]) { + let i = dialogs.length; + + while (i--) { + // The `_openDialogs` property isn't updated after close until the rxjs subscription + // runs on the next microtask, in addition to modifying the array as we're going + // through it. We loop through all of them and call close without assuming that + // they'll be removed from the list instantaneously. + dialogs[i].close(); + } + } + + private _getAfterAllClosed(): Subject { + const parent = this._parentDialog; + return parent ? parent._getAfterAllClosed() : this._afterAllClosedAtThisLevel; } } diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 896402e0c85f..28f7b78cbe8a 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -23,6 +23,7 @@ ng_module( "//src/dev-app/button", "//src/dev-app/button-toggle", "//src/dev-app/card", + "//src/dev-app/cdk-dialog", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", "//src/dev-app/cdk-experimental-menu", diff --git a/src/dev-app/cdk-dialog/BUILD.bazel b/src/dev-app/cdk-dialog/BUILD.bazel new file mode 100644 index 000000000000..f145415a29c2 --- /dev/null +++ b/src/dev-app/cdk-dialog/BUILD.bazel @@ -0,0 +1,22 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "cdk-dialog", + srcs = glob(["**/*.ts"]), + assets = [ + "dialog-demo.html", + ":dialog_demo_scss", + ], + deps = [ + "//src/cdk-experimental/dialog", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) + +sass_binary( + name = "dialog_demo_scss", + src = "dialog-demo.scss", +) diff --git a/src/dev-app/cdk-dialog/dialog-demo-module.ts b/src/dev-app/cdk-dialog/dialog-demo-module.ts new file mode 100644 index 000000000000..bce7f21d03cd --- /dev/null +++ b/src/dev-app/cdk-dialog/dialog-demo-module.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DialogModule} from '@angular/cdk-experimental/dialog'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; +import {DialogDemo, JazzDialog} from './dialog-demo'; + +@NgModule({ + imports: [DialogModule, FormsModule, RouterModule.forChild([{path: '', component: DialogDemo}])], + declarations: [DialogDemo, JazzDialog], +}) +export class DialogDemoModule {} diff --git a/src/dev-app/cdk-dialog/dialog-demo.html b/src/dev-app/cdk-dialog/dialog-demo.html new file mode 100644 index 000000000000..73b473ef3be4 --- /dev/null +++ b/src/dev-app/cdk-dialog/dialog-demo.html @@ -0,0 +1,61 @@ +
+

Dialog config

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Last result: {{result}}

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

It's Jazz!

+ + + + +

{{ data.message }}

+ + +
diff --git a/src/dev-app/cdk-dialog/dialog-demo.scss b/src/dev-app/cdk-dialog/dialog-demo.scss new file mode 100644 index 000000000000..1bdeeec93347 --- /dev/null +++ b/src/dev-app/cdk-dialog/dialog-demo.scss @@ -0,0 +1,23 @@ +.demo-cdk-dialog { + background: white; + padding: 20px; + border-radius: 8px; +} + +.demo-container { + button, label { + margin-right: 8px; + + [dir='rtl'] & { + margin-left: 8px; + margin-right: 0; + } + } +} + +.demo-field { + display: flex; + justify-content: space-between; + max-width: 300px; + margin-bottom: 8px; +} diff --git a/src/dev-app/cdk-dialog/dialog-demo.ts b/src/dev-app/cdk-dialog/dialog-demo.ts new file mode 100644 index 000000000000..33f2eee563f7 --- /dev/null +++ b/src/dev-app/cdk-dialog/dialog-demo.ts @@ -0,0 +1,100 @@ +/** + * @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 {Component, Inject, TemplateRef, ViewChild, ViewEncapsulation} from '@angular/core'; +import {DIALOG_DATA, Dialog, DialogConfig, DialogRef} from '@angular/cdk-experimental/dialog'; + +const defaultDialogConfig = new DialogConfig(); + +@Component({ + selector: 'dialog-demo', + templateUrl: 'dialog-demo.html', + styleUrls: ['dialog-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +export class DialogDemo { + dialogRef: DialogRef | null; + result: string; + actionsAlignment: 'start' | 'center' | 'end'; + config = { + disableClose: defaultDialogConfig.disableClose, + panelClass: 'demo-cdk-dialog', + hasBackdrop: defaultDialogConfig.hasBackdrop, + backdropClass: defaultDialogConfig.backdropClass, + width: defaultDialogConfig.width, + height: defaultDialogConfig.height, + minWidth: defaultDialogConfig.minWidth, + minHeight: defaultDialogConfig.maxHeight, + maxWidth: defaultDialogConfig.maxWidth, + maxHeight: defaultDialogConfig.maxHeight, + data: { + message: 'Jazzy jazz jazz', + hmm: false, + }, + }; + numTemplateOpens = 0; + + @ViewChild(TemplateRef) template: TemplateRef; + + constructor(public dialog: Dialog) {} + + openJazz() { + this.dialogRef = this.dialog.open(JazzDialog, this.config); + + this.dialogRef.closed.subscribe(result => { + this.result = result!; + this.dialogRef = null; + }); + } + + openTemplate() { + this.numTemplateOpens++; + this.dialog.open(this.template, this.config); + } +} + +@Component({ + selector: 'demo-jazz-dialog', + template: ` +
+

It's Jazz!

+ + + + +

{{ data.message }}

+ + + +
+ `, + encapsulation: ViewEncapsulation.None, + styles: [`.hidden-dialog { opacity: 0; }`], +}) +export class JazzDialog { + private _dimesionToggle = false; + + constructor(public dialogRef: DialogRef, @Inject(DIALOG_DATA) public data: any) {} + + togglePosition(): void { + this._dimesionToggle = !this._dimesionToggle; + + if (this._dimesionToggle) { + this.dialogRef.updateSize('500px', '500px'); + } else { + this.dialogRef.updateSize().updatePosition(); + } + } + + temporarilyHide(): void { + this.dialogRef.addPanelClass('hidden-dialog'); + setTimeout(() => { + this.dialogRef.removePanelClass('hidden-dialog'); + }, 2000); + } +} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 725ecbd43137..905cf2ae5018 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -36,6 +36,7 @@ export class DevAppLayout { {name: 'Button Toggle', route: '/button-toggle'}, {name: 'Button', route: '/button'}, {name: 'Card', route: '/card'}, + {name: 'Cdk Dialog', route: '/cdk-dialog'}, {name: 'Cdk Experimental Combobox', route: '/cdk-experimental-combobox'}, {name: 'Cdk Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'Cdk Experimental Menu', route: '/cdk-experimental-menu'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index a1c2fe27d92e..325bd68c17f5 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -50,6 +50,10 @@ export const DEV_APP_ROUTES: Routes = [ m => m.CdkComboboxDemoModule, ), }, + { + path: 'cdk-dialog', + loadChildren: () => import('./cdk-dialog/dialog-demo-module').then(m => m.DialogDemoModule), + }, { path: 'cdk-experimental-listbox', loadChildren: () =>