From 70825a310901b48c1b40b612405740a2dcddfd7f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 28 Apr 2022 09:44:14 +0200 Subject: [PATCH] refactor(cdk/dialog): expand and clean up API Adjusts the public API of the CDK dialog based on a recent feedback session by: * Expanding `DialogRef.restoreFocus` to allow CSS selectors and DOM nodes. * Changing `Dialog.openDialogs`, `DialogRef.componentInstance` and `DialogRef.containerInstance` to be readonly. * Allowing for numbers to be passed in to `DialogRef.updateSize`. * Updating the doc string of `DialogRef.updateSize`. --- src/cdk/dialog/dialog-config.ts | 10 +++- src/cdk/dialog/dialog-container.ts | 19 +++++-- src/cdk/dialog/dialog-ref.ts | 13 +++-- src/cdk/dialog/dialog.spec.ts | 79 +++++++++++++++++++++++++++ src/cdk/dialog/dialog.ts | 12 ++-- src/dev-app/cdk-dialog/dialog-demo.ts | 1 - tools/public_api_guard/cdk/dialog.md | 10 ++-- 7 files changed, 118 insertions(+), 26 deletions(-) diff --git a/src/cdk/dialog/dialog-config.ts b/src/cdk/dialog/dialog-config.ts index d985e37db01a..7f018282a661 100644 --- a/src/cdk/dialog/dialog-config.ts +++ b/src/cdk/dialog/dialog-config.ts @@ -104,10 +104,14 @@ export class DialogConfig /** Restores focus to the element that was focused before the dialog opened. */ private _restoreFocus() { - const previousElement = this._elementFocusedBeforeDialogWasOpened; + const focusConfig = this._config.restoreFocus; + let focusTargetElement: HTMLElement | null = null; + + if (typeof focusConfig === 'string') { + focusTargetElement = this._document.querySelector(focusConfig); + } else if (typeof focusConfig === 'boolean') { + focusTargetElement = focusConfig ? this._elementFocusedBeforeDialogWasOpened : null; + } else if (focusConfig) { + focusTargetElement = focusConfig; + } // We need the extra check, because IE can set the `activeElement` to null in some cases. if ( this._config.restoreFocus && - previousElement && - typeof previousElement.focus === 'function' + focusTargetElement && + typeof focusTargetElement.focus === 'function' ) { const activeElement = _getFocusedElementPierceShadowDom(); const element = this._elementRef.nativeElement; @@ -263,10 +272,10 @@ export class CdkDialogContainer element.contains(activeElement) ) { if (this._focusMonitor) { - this._focusMonitor.focusVia(previousElement, this._closeInteractionType); + this._focusMonitor.focusVia(focusTargetElement, this._closeInteractionType); this._closeInteractionType = null; } else { - previousElement.focus(); + focusTargetElement.focus(); } } } diff --git a/src/cdk/dialog/dialog-ref.ts b/src/cdk/dialog/dialog-ref.ts index e3f57f6efd3e..f5af3f1826b9 100644 --- a/src/cdk/dialog/dialog-ref.ts +++ b/src/cdk/dialog/dialog-ref.ts @@ -27,10 +27,10 @@ export class DialogRef { * Instance of component opened into the dialog. Will be * null when the dialog is opened using a `TemplateRef`. */ - componentInstance: C | null; + readonly componentInstance: C | null; /** Instance of the container that is rendering out the dialog content. */ - containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin}; + readonly containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin}; /** Whether the user is allowed to close the dialog. */ disableClose: boolean | undefined; @@ -86,11 +86,13 @@ export class DialogRef { this.overlayRef.dispose(); closedSubject.next(result); closedSubject.complete(); - this.componentInstance = this.containerInstance = null!; + (this as {componentInstance: C}).componentInstance = ( + this as {containerInstance: BasePortalOutlet} + ).containerInstance = null!; } } - /** Updates the dialog's position. */ + /** Updates the position of the dialog based on the current position strategy. */ updatePosition(): this { this.overlayRef.updatePosition(); return this; @@ -101,9 +103,8 @@ export class DialogRef { * @param width New width of the dialog. * @param height New height of the dialog. */ - updateSize(width: string = '', height: string = ''): this { + updateSize(width: string | number = '', height: string | number = ''): this { this.overlayRef.updateSize({width, height}); - this.overlayRef.updatePosition(); return this; } diff --git a/src/cdk/dialog/dialog.spec.ts b/src/cdk/dialog/dialog.spec.ts index 4ebab1617509..0ae2ee5042bb 100644 --- a/src/cdk/dialog/dialog.spec.ts +++ b/src/cdk/dialog/dialog.spec.ts @@ -934,6 +934,85 @@ describe('Dialog', () => { .withContext('Expected dialog container to be focused.') .toBe('cdk-dialog-container'); })); + + it('should allow for focus restoration to be disabled', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + button.id = 'dialog-trigger'; + document.body.appendChild(button); + button.focus(); + + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + restoreFocus: false, + }); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id).not.toBe('dialog-trigger'); + + dialogRef.close(); + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id).not.toBe('dialog-trigger'); + button.remove(); + })); + + it('should allow for focus to be restored to an element matching a selector', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + button.id = 'dialog-trigger'; + document.body.appendChild(button); + + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + restoreFocus: `#${button.id}`, + }); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id).not.toBe('dialog-trigger'); + + dialogRef.close(); + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id).toBe('dialog-trigger'); + button.remove(); + })); + + it('should allow for focus to be restored to a specific DOM node', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + button.id = 'dialog-trigger'; + document.body.appendChild(button); + + const dialogRef = dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + restoreFocus: button, + }); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id).not.toBe('dialog-trigger'); + + dialogRef.close(); + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id).toBe('dialog-trigger'); + button.remove(); + })); }); describe('aria-label', () => { diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 79da1243ba17..a1d5f9d59702 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -48,7 +48,7 @@ export class Dialog implements OnDestroy { private _scrollStrategy: () => ScrollStrategy; /** Keeps track of the currently-open dialogs. */ - get openDialogs(): DialogRef[] { + get openDialogs(): readonly DialogRef[] { return this._parentDialog ? this._parentDialog.openDialogs : this._openDialogsAtThisLevel; } @@ -129,7 +129,7 @@ export class Dialog implements OnDestroy { const dialogRef = new DialogRef(overlayRef, config); const dialogContainer = this._attachContainer(overlayRef, dialogRef, config); - dialogRef.containerInstance = dialogContainer; + (dialogRef as {containerInstance: BasePortalOutlet}).containerInstance = dialogContainer; this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); // If this is the first dialog that we're opening, hide all the non-overlay content. @@ -137,7 +137,7 @@ export class Dialog implements OnDestroy { this._hideNonDialogContentFromAssistiveTechnology(); } - this.openDialogs.push(dialogRef); + (this.openDialogs as DialogRef[]).push(dialogRef); dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef)); this.afterOpened.next(dialogRef); @@ -278,7 +278,7 @@ export class Dialog implements OnDestroy { config.componentFactoryResolver, ), ); - dialogRef.componentInstance = contentRef.instance; + (dialogRef as {componentInstance: C}).componentInstance = contentRef.instance; } } @@ -331,7 +331,7 @@ export class Dialog implements OnDestroy { const index = this.openDialogs.indexOf(dialogRef); if (index > -1) { - this.openDialogs.splice(index, 1); + (this.openDialogs as DialogRef[]).splice(index, 1); // If all the dialogs were closed, remove/restore the `aria-hidden` // to a the siblings and emit to the `afterAllClosed` stream. @@ -375,7 +375,7 @@ export class Dialog implements OnDestroy { } /** Closes all of the dialogs in an array. */ - private _closeDialogs(dialogs: DialogRef[]) { + private _closeDialogs(dialogs: readonly DialogRef[]) { let i = dialogs.length; while (i--) { diff --git a/src/dev-app/cdk-dialog/dialog-demo.ts b/src/dev-app/cdk-dialog/dialog-demo.ts index 3a42807b0afe..e20dba26a371 100644 --- a/src/dev-app/cdk-dialog/dialog-demo.ts +++ b/src/dev-app/cdk-dialog/dialog-demo.ts @@ -34,7 +34,6 @@ export class DialogDemo { maxHeight: defaultDialogConfig.maxHeight, data: { message: 'Jazzy jazz jazz', - hmm: false, }, }; numTemplateOpens = 0; diff --git a/tools/public_api_guard/cdk/dialog.md b/tools/public_api_guard/cdk/dialog.md index 753d95a291b9..c140703aded1 100644 --- a/tools/public_api_guard/cdk/dialog.md +++ b/tools/public_api_guard/cdk/dialog.md @@ -89,7 +89,7 @@ export class Dialog implements OnDestroy { open(template: TemplateRef, config?: DialogConfig>): DialogRef; // (undocumented) open(componentOrTemplateRef: ComponentType | TemplateRef, config?: DialogConfig>): DialogRef; - get openDialogs(): DialogRef[]; + get openDialogs(): readonly DialogRef[]; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -145,7 +145,7 @@ export class DialogConfig, container: C) => StaticProvider[]); - restoreFocus?: boolean; + restoreFocus?: boolean | string | HTMLElement; role?: DialogRole; scrollStrategy?: ScrollStrategy; templateContext?: Record | (() => Record); @@ -170,10 +170,10 @@ export class DialogRef { readonly backdropClick: Observable; close(result?: R, options?: DialogCloseOptions): void; readonly closed: Observable; - componentInstance: C | null; + readonly componentInstance: C | null; // (undocumented) readonly config: DialogConfig, BasePortalOutlet>; - containerInstance: BasePortalOutlet & { + readonly containerInstance: BasePortalOutlet & { _closeInteractionType?: FocusOrigin; }; disableClose: boolean | undefined; @@ -184,7 +184,7 @@ export class DialogRef { readonly overlayRef: OverlayRef; removePanelClass(classes: string | string[]): this; updatePosition(): this; - updateSize(width?: string, height?: string): this; + updateSize(width?: string | number, height?: string | number): this; } // @public