From 7d7d225a4e2b0f091d77deb19f56edc9b5f6492e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 23 Jan 2018 19:55:10 +0100 Subject: [PATCH] feat(dialog): support using dialog content directives with template dialogs 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. --- src/lib/dialog/dialog-container.ts | 4 + src/lib/dialog/dialog-content-directives.ts | 69 ++++++++-- src/lib/dialog/dialog-ref.ts | 5 +- src/lib/dialog/dialog.spec.ts | 142 +++++++++++++------- 4 files changed, 159 insertions(+), 61 deletions(-) diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index ee34a1eed0ce..7129c41cbe30 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -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', @@ -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, diff --git a/src/lib/dialog/dialog-content-directives.ts b/src/lib/dialog/dialog-content-directives.ts index 72f072f7d120..c3f5b7bb4bc4 100644 --- a/src/lib/dialog/dialog-content-directives.ts +++ b/src/lib/dialog/dialog-content-directives.ts @@ -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; @@ -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'; @@ -34,7 +42,21 @@ export class MatDialogClose implements OnChanges { @Input('matDialogClose') _matDialogClose: any; - constructor(public dialogRef: MatDialogRef) { } + constructor( + @Optional() public dialogRef: MatDialogRef, + private _elementRef: ElementRef, + private _dialog: MatDialog) {} + + ngOnInit() { + if (!this.dialogRef) { + // When this directive is included in a dialog via TemplateRef (rather than being + // in a Component), the DialogRef isn't available via injection because embedded + // views cannot be given a custom injector. Instead, we look up the DialogRef by + // ID. This must occur in `onInit`, as the ID binding for the dialog container won't + // be resolved at constructor time. + this.dialogRef = getClosestDialog(this._elementRef, this._dialog.openDialogs)!; + } + } ngOnChanges(changes: SimpleChanges) { const proxiedChange = changes._matDialogClose || changes._matDialogCloseResult; @@ -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, + 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; + } + }); } } } @@ -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 {} /** @@ -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[]) { + 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; +} diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index 2a54119369ca..6e640ab43797 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -50,10 +50,13 @@ export class MatDialogRef { 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'), diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 51b615e92707..8cf11517441f 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -958,72 +958,88 @@ describe('MatDialog', () => { }); describe('dialog content elements', () => { - let dialogRef: MatDialogRef; + let dialogRef: MatDialogRef; - 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', () => { @@ -1277,14 +1293,37 @@ class PizzaMsg {

This is the title

Lorem ipsum dolor sit amet. - + +
Should not close
` }) -class ContentElementDialog { - closeButtonAriaLabel: string; +class ContentElementDialog {} + +@Component({ + template: ` + +

This is the title

+ Lorem ipsum dolor sit amet. + + + + +
Should not close
+
+
+ ` +}) +class ComponentWithContentElementTemplateRef { + @ViewChild(TemplateRef) templateRef: TemplateRef; } @Component({ @@ -1314,7 +1353,8 @@ const TEST_DIRECTIVES = [ ComponentWithOnPushViewContainer, ContentElementDialog, DialogWithInjectedData, - DialogWithoutFocusableElements + DialogWithoutFocusableElements, + ComponentWithContentElementTemplateRef, ]; @NgModule({