Skip to content

Commit

Permalink
feat(dialog): support using dialog content directives with template d…
Browse files Browse the repository at this point in the history
…ialogs (#9379)

Previously the `matDialogClose`, `matDialogTitle` etc. directives would only work correctly inside component dialogs, because using DI to get the dialog ref doesn't work inside template dialogs. These changes add a fallback that finds the dialog ref based on the id of the closest dialog container.

Fixes #5412.
  • Loading branch information
crisbeto authored and jelbourn committed Jan 24, 2018
1 parent dfa68db commit 99b768e
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 61 deletions.
4 changes: 4 additions & 0 deletions src/lib/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function throwMatDialogContentAlreadyAttachedError() {
host: {
'class': 'mat-dialog-container',
'tabindex': '-1',
'[attr.id]': '_id',
'[attr.role]': '_config?.role',
'[attr.aria-labelledby]': '_config?.ariaLabel ? null : _ariaLabelledBy',
'[attr.aria-label]': '_config?.ariaLabel',
Expand Down Expand Up @@ -91,6 +92,9 @@ export class MatDialogContainer extends BasePortalOutlet {
/** ID of the element that should be considered as the dialog's label. */
_ariaLabelledBy: string | null = null;

/** ID for the container DOM element. */
_id: string;

constructor(
private _elementRef: ElementRef,
private _focusTrapFactory: FocusTrapFactory,
Expand Down
69 changes: 60 additions & 9 deletions src/lib/dialog/dialog-content-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, Input, OnChanges, OnInit, Optional, SimpleChanges} from '@angular/core';
import {
Directive,
Input,
OnChanges,
OnInit,
Optional,
SimpleChanges,
ElementRef,
} from '@angular/core';
import {MatDialog} from './dialog';
import {MatDialogRef} from './dialog-ref';
import {MatDialogContainer} from './dialog-container';

/** Counter used to generate unique IDs for dialog elements. */
let dialogElementUid = 0;
Expand All @@ -25,7 +33,7 @@ let dialogElementUid = 0;
'type': 'button', // Prevents accidental form submits.
}
})
export class MatDialogClose implements OnChanges {
export class MatDialogClose implements OnInit, OnChanges {
/** Screenreader label for the button. */
@Input('aria-label') ariaLabel: string = 'Close dialog';

Expand All @@ -34,7 +42,21 @@ export class MatDialogClose implements OnChanges {

@Input('matDialogClose') _matDialogClose: any;

constructor(public dialogRef: MatDialogRef<any>) { }
constructor(
@Optional() public dialogRef: MatDialogRef<any>,
private _elementRef: ElementRef,
private _dialog: MatDialog) {}

ngOnInit() {
if (!this.dialogRef) {
// When this directive is included in a dialog via TemplateRef (rather than being
// in a Component), the DialogRef isn't available via injection because embedded
// views cannot be given a custom injector. Instead, we look up the DialogRef by
// ID. This must occur in `onInit`, as the ID binding for the dialog container won't
// be resolved at constructor time.
this.dialogRef = getClosestDialog(this._elementRef, this._dialog.openDialogs)!;
}
}

ngOnChanges(changes: SimpleChanges) {
const proxiedChange = changes._matDialogClose || changes._matDialogCloseResult;
Expand All @@ -59,11 +81,24 @@ export class MatDialogClose implements OnChanges {
export class MatDialogTitle implements OnInit {
@Input() id = `mat-dialog-title-${dialogElementUid++}`;

constructor(@Optional() private _container: MatDialogContainer) { }
constructor(
@Optional() private _dialogRef: MatDialogRef<any>,
private _elementRef: ElementRef,
private _dialog: MatDialog) {}

ngOnInit() {
if (this._container && !this._container._ariaLabelledBy) {
Promise.resolve().then(() => this._container._ariaLabelledBy = this.id);
if (!this._dialogRef) {
this._dialogRef = getClosestDialog(this._elementRef, this._dialog.openDialogs)!;
}

if (this._dialogRef) {
Promise.resolve().then(() => {
const container = this._dialogRef._containerInstance;

if (container && !container._ariaLabelledBy) {
container._ariaLabelledBy = this.id;
}
});
}
}
}
Expand All @@ -76,7 +111,7 @@ export class MatDialogTitle implements OnInit {
selector: `[mat-dialog-content], mat-dialog-content, [matDialogContent]`,
host: {'class': 'mat-dialog-content'}
})
export class MatDialogContent { }
export class MatDialogContent {}


/**
Expand All @@ -87,4 +122,20 @@ export class MatDialogContent { }
selector: `[mat-dialog-actions], mat-dialog-actions, [matDialogActions]`,
host: {'class': 'mat-dialog-actions'}
})
export class MatDialogActions { }
export class MatDialogActions {}


/**
* Finds the closest MatDialogRef to an element by looking at the DOM.
* @param element Element relative to which to look for a dialog.
* @param openDialogs References to the currently-open dialogs.
*/
function getClosestDialog(element: ElementRef, openDialogs: MatDialogRef<any>[]) {
let parent: HTMLElement | null = element.nativeElement.parentElement;

while (parent && !parent.classList.contains('mat-dialog-container')) {
parent = parent.parentElement;
}

return parent ? openDialogs.find(dialog => dialog.id === parent!.id) : null;
}
5 changes: 4 additions & 1 deletion src/lib/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,13 @@ export class MatDialogRef<T, R = any> {

constructor(
private _overlayRef: OverlayRef,
private _containerInstance: MatDialogContainer,
public _containerInstance: MatDialogContainer,
location?: Location,
readonly id: string = `mat-dialog-${uniqueId++}`) {

// Pass the id along to the container.
_containerInstance._id = id;

// Emit when opening animation completes
_containerInstance._animationStateChanged.pipe(
filter(event => event.phaseName === 'done' && event.toState === 'enter'),
Expand Down
142 changes: 91 additions & 51 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,72 +958,88 @@ describe('MatDialog', () => {
});

describe('dialog content elements', () => {
let dialogRef: MatDialogRef<ContentElementDialog>;
let dialogRef: MatDialogRef<any>;

beforeEach(fakeAsync(() => {
dialogRef = dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
viewContainerFixture.detectChanges();
flush();
}));

it('should close the dialog when clicking on the close button', fakeAsync(() => {
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);
describe('inside component dialog', () => {
beforeEach(fakeAsync(() => {
dialogRef = dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
viewContainerFixture.detectChanges();
flush();
}));

(overlayContainerElement.querySelector('button[mat-dialog-close]') as HTMLElement).click();
viewContainerFixture.detectChanges();
flush();
runContentElementTests();
});

expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(0);
}));
describe('inside template portal', () => {
beforeEach(fakeAsync(() => {
const fixture = TestBed.createComponent(ComponentWithContentElementTemplateRef);
fixture.detectChanges();

it('should not close the dialog if [mat-dialog-close] is applied on a non-button node', () => {
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);
dialogRef = dialog.open(fixture.componentInstance.templateRef, {
viewContainerRef: testViewContainerRef
});

(overlayContainerElement.querySelector('div[mat-dialog-close]') as HTMLElement).click();
viewContainerFixture.detectChanges();
flush();
}));

expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);
runContentElementTests();
});

it('should allow for a user-specified aria-label on the close button', fakeAsync(() => {
let button = overlayContainerElement.querySelector('button[mat-dialog-close]')!;
function runContentElementTests() {
it('should close the dialog when clicking on the close button', fakeAsync(() => {
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);

dialogRef.componentInstance.closeButtonAriaLabel = 'Best close button ever';
viewContainerFixture.detectChanges();
flush();
(overlayContainerElement.querySelector('button[mat-dialog-close]') as HTMLElement).click();
viewContainerFixture.detectChanges();
flush();

expect(button.getAttribute('aria-label')).toBe('Best close button ever');
}));
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(0);
}));

it('should override the "type" attribute of the close button', () => {
let button = overlayContainerElement.querySelector('button[mat-dialog-close]')!;
it('should not close if [mat-dialog-close] is applied on a non-button node', () => {
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);

expect(button.getAttribute('type')).toBe('button');
});
(overlayContainerElement.querySelector('div[mat-dialog-close]') as HTMLElement).click();

it('should return the [mat-dialog-close] result when clicking the close button',
fakeAsync(() => {
let afterCloseCallback = jasmine.createSpy('afterClose callback');
dialogRef.afterClosed().subscribe(afterCloseCallback);

(overlayContainerElement.querySelector('button.close-with-true') as HTMLElement).click();
viewContainerFixture.detectChanges();
flush();
expect(overlayContainerElement.querySelectorAll('.mat-dialog-container').length).toBe(1);
});

expect(afterCloseCallback).toHaveBeenCalledWith(true);
it('should allow for a user-specified aria-label on the close button', fakeAsync(() => {
let button = overlayContainerElement.querySelector('.close-with-aria-label')!;
expect(button.getAttribute('aria-label')).toBe('Best close button ever');
}));

it('should set the aria-labelledby attribute to the id of the title', fakeAsync(() => {
let title = overlayContainerElement.querySelector('[mat-dialog-title]')!;
let container = overlayContainerElement.querySelector('mat-dialog-container')!;
it('should override the "type" attribute of the close button', () => {
let button = overlayContainerElement.querySelector('button[mat-dialog-close]')!;

flush();
viewContainerFixture.detectChanges();
expect(button.getAttribute('type')).toBe('button');
});

expect(title.id).toBeTruthy('Expected title element to have an id.');
expect(container.getAttribute('aria-labelledby'))
.toBe(title.id, 'Expected the aria-labelledby to match the title id.');
}));
it('should return the [mat-dialog-close] result when clicking the close button',
fakeAsync(() => {
let afterCloseCallback = jasmine.createSpy('afterClose callback');
dialogRef.afterClosed().subscribe(afterCloseCallback);

(overlayContainerElement.querySelector('button.close-with-true') as HTMLElement).click();
viewContainerFixture.detectChanges();
flush();

expect(afterCloseCallback).toHaveBeenCalledWith(true);
}));

it('should set the aria-labelledby attribute to the id of the title', fakeAsync(() => {
let title = overlayContainerElement.querySelector('[mat-dialog-title]')!;
let container = overlayContainerElement.querySelector('mat-dialog-container')!;

flush();
viewContainerFixture.detectChanges();

expect(title.id).toBeTruthy('Expected title element to have an id.');
expect(container.getAttribute('aria-labelledby'))
.toBe(title.id, 'Expected the aria-labelledby to match the title id.');
}));
}
});

describe('aria-label', () => {
Expand Down Expand Up @@ -1277,14 +1293,37 @@ class PizzaMsg {
<h1 mat-dialog-title>This is the title</h1>
<mat-dialog-content>Lorem ipsum dolor sit amet.</mat-dialog-content>
<mat-dialog-actions>
<button mat-dialog-close [aria-label]="closeButtonAriaLabel">Close</button>
<button mat-dialog-close>Close</button>
<button class="close-with-true" [mat-dialog-close]="true">Close and return true</button>
<button
class="close-with-aria-label"
aria-label="Best close button ever"
[mat-dialog-close]="true">Close</button>
<div mat-dialog-close>Should not close</div>
</mat-dialog-actions>
`
})
class ContentElementDialog {
closeButtonAriaLabel: string;
class ContentElementDialog {}

@Component({
template: `
<ng-template>
<h1 mat-dialog-title>This is the title</h1>
<mat-dialog-content>Lorem ipsum dolor sit amet.</mat-dialog-content>
<mat-dialog-actions>
<button mat-dialog-close>Close</button>
<button class="close-with-true" [mat-dialog-close]="true">Close and return true</button>
<button
class="close-with-aria-label"
aria-label="Best close button ever"
[mat-dialog-close]="true">Close</button>
<div mat-dialog-close>Should not close</div>
</mat-dialog-actions>
</ng-template>
`
})
class ComponentWithContentElementTemplateRef {
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
}

@Component({
Expand Down Expand Up @@ -1314,7 +1353,8 @@ const TEST_DIRECTIVES = [
ComponentWithOnPushViewContainer,
ContentElementDialog,
DialogWithInjectedData,
DialogWithoutFocusableElements
DialogWithoutFocusableElements,
ComponentWithContentElementTemplateRef,
];

@NgModule({
Expand Down

0 comments on commit 99b768e

Please sign in to comment.