Skip to content

Commit 4d675d6

Browse files
josephperrottandrewseguin
authored andcommitted
Create Dialog in CDK. (#8844)
1 parent dc04596 commit 4d675d6

15 files changed

+1984
-1
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474

7575
# Material experimental package
7676
/src/material-experimental/** @jelbourn
77+
/src/material-experimental/dialog/** @jelbourn @josephperrott @crisbeto
7778

7879
# Docs examples & guides
7980
/guides/** @amcdnl @jelbourn
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {ViewContainerRef} from '@angular/core';
9+
import {Direction} from '@angular/cdk/bidi';
10+
import {ComponentType} from '@angular/cdk/overlay';
11+
import {CdkDialogContainer} from './dialog-container';
12+
13+
/** Valid ARIA roles for a dialog element. */
14+
export type DialogRole = 'dialog' | 'alertdialog';
15+
16+
/** Possible overrides for a dialog's position. */
17+
export interface DialogPosition {
18+
top?: string;
19+
bottom?: string;
20+
left?: string;
21+
right?: string;
22+
}
23+
24+
export class DialogConfig<D = any> {
25+
/** Component to use as the container for the dialog. */
26+
containerComponent?: ComponentType<CdkDialogContainer>;
27+
28+
/**
29+
* Where the attached component should live in Angular's *logical* component tree.
30+
* This affects what is available for injection and the change detection order for the
31+
* component instantiated inside of the dialog. This does not affect where the dialog
32+
* content will be rendered.
33+
*/
34+
viewContainerRef?: ViewContainerRef;
35+
36+
/** The id of the dialog. */
37+
id?: string;
38+
39+
/** The ARIA role of the dialog. */
40+
role?: DialogRole = 'dialog';
41+
42+
/** Custom class(es) for the overlay panel. */
43+
panelClass?: string | string[] = '';
44+
45+
/** Custom class(es) for the dialog container. */
46+
containerClass?: string | string[] = '';
47+
48+
/** Whether the dialog has a background. */
49+
hasBackdrop?: boolean = true;
50+
51+
/** Custom class(es) for the backdrop. */
52+
backdropClass?: string | undefined = '';
53+
54+
/** Whether the dialog can be closed by user interaction. */
55+
disableClose?: boolean = false;
56+
57+
/** The width of the dialog. */
58+
width?: string = '';
59+
60+
/** The height of the dialog. */
61+
height?: string = '';
62+
63+
/** The minimum width of the dialog. */
64+
minWidth?: string | number = '';
65+
66+
/** The minimum height of the dialog. */
67+
minHeight?: string | number = '';
68+
69+
/** The maximum width of the dialog. */
70+
maxWidth?: string | number = '80vw';
71+
72+
/** The maximum height of the dialog. */
73+
maxHeight?: string | number = '';
74+
75+
/** The position of the dialog. */
76+
position?: DialogPosition;
77+
78+
/** Data to be injected into the dialog content. */
79+
data?: D | null = null;
80+
81+
/** The layout direction for the dialog content. */
82+
direction?: Direction = 'ltr';
83+
84+
/** ID of the element that describes the dialog. */
85+
ariaDescribedBy?: string | null = null;
86+
87+
/** Aria label to assign to the dialog element */
88+
ariaLabel?: string | null = null;
89+
90+
/** Whether the dialog should focus the first focusable element on open. */
91+
autoFocus?: boolean = true;
92+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<ng-template cdkPortalOutlet></ng-template>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
cdk-dialog-container {
2+
background: white;
3+
border-radius: 5px;
4+
display: block;
5+
padding: 10px;
6+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
ElementRef,
11+
HostBinding,
12+
ViewChild,
13+
ComponentRef,
14+
EmbeddedViewRef,
15+
ChangeDetectorRef,
16+
Component,
17+
Optional,
18+
Inject,
19+
ViewEncapsulation,
20+
ChangeDetectionStrategy,
21+
} from '@angular/core';
22+
import {DOCUMENT} from '@angular/common';
23+
import {trigger, state, style, transition, animate, AnimationEvent} from '@angular/animations';
24+
import {
25+
BasePortalOutlet,
26+
PortalHostDirective,
27+
ComponentPortal,
28+
TemplatePortal
29+
} from '@angular/cdk/portal';
30+
import {FocusTrapFactory} from '@angular/cdk/a11y';
31+
import {DialogConfig} from './dialog-config';
32+
import {Subject} from 'rxjs/Subject';
33+
34+
35+
export function throwDialogContentAlreadyAttachedError() {
36+
throw Error('Attempting to attach dialog content after content is already attached');
37+
}
38+
39+
40+
/**
41+
* Internal component that wraps user-provided dialog content.
42+
* @docs-private
43+
*/
44+
@Component({
45+
moduleId: module.id,
46+
selector: 'cdk-dialog-container',
47+
templateUrl: './dialog-container.html',
48+
styleUrls: ['dialog-container.css'],
49+
encapsulation: ViewEncapsulation.None,
50+
preserveWhitespaces: false,
51+
// Using OnPush for dialogs caused some G3 sync issues. Disabled until we can track them down.
52+
// tslint:disable-next-line:validate-decorators
53+
changeDetection: ChangeDetectionStrategy.Default,
54+
animations: [
55+
trigger('dialog', [
56+
state('enter', style({ opacity: 1 })),
57+
state('exit, void', style({ opacity: 0 })),
58+
transition('* => *', animate(225)),
59+
])
60+
],
61+
host: {
62+
'[@dialog]': '_state',
63+
'(@dialog.start)': '_onAnimationStart($event)',
64+
'(@dialog.done)': '_onAnimationDone($event)',
65+
},
66+
})
67+
export class CdkDialogContainer extends BasePortalOutlet {
68+
/** State of the dialog animation. */
69+
_state: 'void' | 'enter' | 'exit' = 'enter';
70+
71+
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
72+
private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null;
73+
74+
/** The class that traps and manages focus within the dialog. */
75+
private _focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, false);
76+
77+
// @HostBinding is used in the class as it is expected to be extended. Since @Component decorator
78+
// metadata is not inherited by child classes, instead the host binding data is defined in a way
79+
// that can be inherited.
80+
// tslint:disable:no-host-decorator-in-concrete
81+
@HostBinding('attr.aria-label') get _ariaLabel() { return this._config.ariaLabel || null; }
82+
83+
@HostBinding('attr.aria-describedby')
84+
get _ariaDescribedBy() { return this._config ? this._config.ariaDescribedBy : null; }
85+
86+
@HostBinding('attr.role') get _role() { return this._config ? this._config.role : null; }
87+
88+
@HostBinding('attr.tabindex') get _tabindex() { return -1; }
89+
// tslint:disable:no-host-decorator-in-concrete
90+
91+
/** The portal host inside of this container into which the dialog content will be loaded. */
92+
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
93+
94+
/** A subject emitting before the dialog enters the view. */
95+
_beforeEnter: Subject<void> = new Subject();
96+
97+
/** A subject emitting after the dialog enters the view. */
98+
_afterEnter: Subject<void> = new Subject();
99+
100+
/** A subject emitting before the dialog exits the view. */
101+
_beforeExit: Subject<void> = new Subject();
102+
103+
/** A subject emitting after the dialog exits the view. */
104+
_afterExit: Subject<void> = new Subject();
105+
106+
/** The dialog configuration. */
107+
_config: DialogConfig;
108+
109+
constructor(
110+
private _elementRef: ElementRef,
111+
private _focusTrapFactory: FocusTrapFactory,
112+
private _changeDetectorRef: ChangeDetectorRef,
113+
@Optional() @Inject(DOCUMENT) private _document: any) {
114+
super();
115+
}
116+
117+
/** Destroy focus trap to place focus back to the element focused before the dialog opened. */
118+
ngOnDestroy() {
119+
this._focusTrap.destroy();
120+
}
121+
122+
/**
123+
* Attach a ComponentPortal as content to this dialog container.
124+
* @param portal Portal to be attached as the dialog content.
125+
*/
126+
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
127+
if (this._portalHost.hasAttached()) {
128+
throwDialogContentAlreadyAttachedError();
129+
}
130+
131+
this._savePreviouslyFocusedElement();
132+
return this._portalHost.attachComponentPortal(portal);
133+
}
134+
135+
/**
136+
* Attach a TemplatePortal as content to this dialog container.
137+
* @param portal Portal to be attached as the dialog content.
138+
*/
139+
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
140+
if (this._portalHost.hasAttached()) {
141+
throwDialogContentAlreadyAttachedError();
142+
}
143+
144+
this._savePreviouslyFocusedElement();
145+
return this._portalHost.attachTemplatePortal(portal);
146+
}
147+
148+
/** Emit lifecycle events based on animation `start` callback. */
149+
_onAnimationStart(event: AnimationEvent) {
150+
if (event.toState === 'enter') {
151+
this._beforeEnter.next();
152+
}
153+
if (event.toState === 'void' || event.toState === 'exit') {
154+
this._beforeExit.next();
155+
}
156+
}
157+
158+
/** Emit lifecycle events based on animation `done` callback. */
159+
_onAnimationDone(event: AnimationEvent) {
160+
if (event.toState === 'enter') {
161+
this._autoFocusFirstTabbableElement();
162+
this._afterEnter.next();
163+
}
164+
if (event.toState === 'void' || event.toState === 'exit') {
165+
this._returnFocusAfterDialog();
166+
this._afterExit.next();
167+
}
168+
}
169+
170+
/** Starts the dialog exit animation. */
171+
_startExiting(): void {
172+
this._state = 'exit';
173+
174+
// Mark the container for check so it can react if the
175+
// view container is using OnPush change detection.
176+
this._changeDetectorRef.markForCheck();
177+
}
178+
179+
/** Saves a reference to the element that was focused before the dialog was opened. */
180+
private _savePreviouslyFocusedElement() {
181+
if (this._document) {
182+
this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement;
183+
184+
// Move focus onto the dialog immediately in order to prevent the user from accidentally
185+
// opening multiple dialogs at the same time. Needs to be async, because the element
186+
// may not be focusable immediately.
187+
Promise.resolve().then(() => this._elementRef.nativeElement.focus());
188+
}
189+
}
190+
191+
/**
192+
* Autofocus the first tabbable element inside of the dialog, if there is not a tabbable element,
193+
* focus the dialog instead.
194+
*/
195+
private _autoFocusFirstTabbableElement() {
196+
// If were to attempt to focus immediately, then the content of the dialog would not yet be
197+
// ready in instances where change detection has to run first. To deal with this, we simply
198+
// wait for the microtask queue to be empty.
199+
if (this._config.autoFocus) {
200+
this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => {
201+
// If we didn't find any focusable elements inside the dialog, focus the
202+
// container so the user can't tab into other elements behind it.
203+
if (!hasMovedFocus) {
204+
this._elementRef.nativeElement.focus();
205+
}
206+
});
207+
}
208+
}
209+
210+
/** Returns the focus to the element focused before the dialog was open. */
211+
private _returnFocusAfterDialog() {
212+
const toFocus = this._elementFocusedBeforeDialogWasOpened;
213+
// We need the extra check, because IE can set the `activeElement` to null in some cases.
214+
if (toFocus && typeof toFocus.focus === 'function') {
215+
toFocus.focus();
216+
}
217+
}
218+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import {ComponentType, Overlay, ScrollStrategy, BlockScrollStrategy} from '@angular/cdk/overlay';
11+
import {DialogRef} from './dialog-ref';
12+
import {CdkDialogContainer} from './dialog-container';
13+
import {DialogConfig} from './dialog-config';
14+
15+
/** Injection token for the Dialog's ScrollStrategy. */
16+
export const DIALOG_SCROLL_STRATEGY =
17+
new InjectionToken<() => ScrollStrategy>('DialogScrollStrategy');
18+
19+
/** Injection token for the Dialog's Data. */
20+
export const DIALOG_DATA = new InjectionToken<any>('DialogData');
21+
22+
/** Injection token for the DialogRef constructor. */
23+
export const DIALOG_REF = new InjectionToken<DialogRef<any>>('DialogRef');
24+
25+
/** Injection token for the DialogConfig. */
26+
export const DIALOG_CONFIG = new InjectionToken<DialogConfig>('DialogConfig');
27+
28+
/** Injection token for the Dialog's DialogContainer component. */
29+
export const DIALOG_CONTAINER =
30+
new InjectionToken<ComponentType<CdkDialogContainer>>('DialogContainer');
31+
32+
/** @docs-private */
33+
export function MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay):
34+
() => BlockScrollStrategy {
35+
return () => overlay.scrollStrategies.block();
36+
}
37+
38+
/** @docs-private */
39+
export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = {
40+
provide: DIALOG_SCROLL_STRATEGY,
41+
deps: [Overlay],
42+
useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY,
43+
};

0 commit comments

Comments
 (0)