Skip to content

Commit 85e4486

Browse files
authored
fix(cdk/dialog): avoid setting aria-hidden before focus has moved (#31030) (#31036)
The dialog moves focus in an `afterRender`, because it needs to give the content some time to be rendered. This is problematic with some relatively recent behavior in Chrome where the `aria-hidden` gets blocked and a warning is logged if it contains the focused element. These changes add a way for the container to indicate when it's done moving focus and use the new API to apply the `aria-hidden`. Fixes #30187.
1 parent 7664411 commit 85e4486

File tree

5 files changed

+58
-33
lines changed

5 files changed

+58
-33
lines changed

goldens/cdk/dialog/index.api.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { ViewContainerRef } from '@angular/core';
3838
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
3939

4040
// @public
41-
export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends BasePortalOutlet implements OnDestroy {
41+
export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends BasePortalOutlet implements DialogContainer, OnDestroy {
4242
constructor(...args: unknown[]);
4343
// (undocumented)
4444
_addAriaLabelledBy(id: string): void;
@@ -62,6 +62,8 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends B
6262
// (undocumented)
6363
protected _focusTrapFactory: FocusTrapFactory;
6464
// (undocumented)
65+
_focusTrapped: Observable<void>;
66+
// (undocumented)
6567
ngOnDestroy(): void;
6668
// (undocumented)
6769
protected _ngZone: NgZone;
@@ -121,7 +123,7 @@ export interface DialogCloseOptions {
121123
}
122124

123125
// @public
124-
export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet = BasePortalOutlet> {
126+
export class DialogConfig<D = unknown, R = unknown, C extends DialogContainer = BasePortalOutlet> {
125127
ariaDescribedBy?: string | null;
126128
ariaLabel?: string | null;
127129
ariaLabelledBy?: string | null;
@@ -159,6 +161,13 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
159161
width?: string;
160162
}
161163

164+
// @public
165+
export type DialogContainer = BasePortalOutlet & {
166+
_focusTrapped?: Observable<void>;
167+
_closeInteractionType?: FocusOrigin;
168+
_recaptureFocus?: () => void;
169+
};
170+
162171
// @public (undocumented)
163172
export class DialogModule {
164173
// (undocumented)
@@ -171,18 +180,16 @@ export class DialogModule {
171180

172181
// @public
173182
export class DialogRef<R = unknown, C = unknown> {
174-
constructor(overlayRef: OverlayRef, config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>);
183+
constructor(overlayRef: OverlayRef, config: DialogConfig<any, DialogRef<R, C>, DialogContainer>);
175184
addPanelClass(classes: string | string[]): this;
176185
readonly backdropClick: Observable<MouseEvent>;
177186
close(result?: R, options?: DialogCloseOptions): void;
178187
readonly closed: Observable<R | undefined>;
179188
readonly componentInstance: C | null;
180189
readonly componentRef: ComponentRef<C> | null;
181190
// (undocumented)
182-
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>;
183-
readonly containerInstance: BasePortalOutlet & {
184-
_closeInteractionType?: FocusOrigin;
185-
};
191+
readonly config: DialogConfig<any, DialogRef<R, C>, DialogContainer>;
192+
readonly containerInstance: DialogContainer;
186193
disableClose: boolean | undefined;
187194
readonly id: string;
188195
readonly keydownEvents: Observable<KeyboardEvent>;

src/cdk/dialog/dialog-config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@
99
import {ViewContainerRef, Injector, StaticProvider, Type} from '@angular/core';
1010
import {Direction} from '../bidi';
1111
import {PositionStrategy, ScrollStrategy} from '../overlay';
12+
import {Observable} from 'rxjs';
1213
import {BasePortalOutlet} from '../portal';
14+
import {FocusOrigin} from '../a11y';
1315

1416
/** Options for where to set focus to automatically on dialog open */
1517
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
1618

1719
/** Valid ARIA roles for a dialog. */
1820
export type DialogRole = 'dialog' | 'alertdialog';
1921

22+
/** Component that can be used as the container for the dialog. */
23+
export type DialogContainer = BasePortalOutlet & {
24+
_focusTrapped?: Observable<void>;
25+
_closeInteractionType?: FocusOrigin;
26+
_recaptureFocus?: () => void;
27+
};
28+
2029
/** Configuration for opening a modal dialog. */
21-
export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet = BasePortalOutlet> {
30+
export class DialogConfig<D = unknown, R = unknown, C extends DialogContainer = BasePortalOutlet> {
2231
/**
2332
* Where the attached component should live in Angular's *logical* component tree.
2433
* This affects what is available for injection and the change detection order for the

src/cdk/dialog/dialog-container.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import {
3939
afterNextRender,
4040
inject,
4141
} from '@angular/core';
42-
import {DialogConfig} from './dialog-config';
42+
import {DialogConfig, DialogContainer} from './dialog-config';
43+
import {Observable, Subject} from 'rxjs';
4344

4445
export function throwDialogContentAlreadyAttachedError() {
4546
throw Error('Attempting to attach dialog content after content is already attached');
@@ -71,7 +72,7 @@ export function throwDialogContentAlreadyAttachedError() {
7172
})
7273
export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
7374
extends BasePortalOutlet
74-
implements OnDestroy
75+
implements DialogContainer, OnDestroy
7576
{
7677
protected _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
7778
protected _focusTrapFactory = inject(FocusTrapFactory);
@@ -81,13 +82,16 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
8182
private _overlayRef = inject(OverlayRef);
8283
private _focusMonitor = inject(FocusMonitor);
8384
private _renderer = inject(Renderer2);
84-
85+
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
86+
private _injector = inject(Injector);
8587
private _platform = inject(Platform);
8688
protected _document = inject(DOCUMENT, {optional: true})!;
8789

8890
/** The portal outlet inside of this container into which the dialog content will be loaded. */
8991
@ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet;
9092

93+
_focusTrapped: Observable<void> = new Subject<void>();
94+
9195
/** The class that traps and manages focus within the dialog. */
9296
private _focusTrap: FocusTrap | null = null;
9397

@@ -109,10 +113,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
109113
*/
110114
_ariaLabelledByQueue: string[] = [];
111115

112-
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
113-
114-
private _injector = inject(Injector);
115-
116116
private _isDestroyed = false;
117117

118118
constructor(...args: unknown[]);
@@ -158,6 +158,7 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
158158
}
159159

160160
ngOnDestroy() {
161+
(this._focusTrapped as Subject<void>).complete();
161162
this._isDestroyed = true;
162163
this._restoreFocus();
163164
}
@@ -293,6 +294,7 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
293294
this._focusByCssSelector(this._config.autoFocus!, options);
294295
break;
295296
}
297+
(this._focusTrapped as Subject<void>).next();
296298
},
297299
{injector: this._injector},
298300
);

src/cdk/dialog/dialog-ref.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@
99
import {OverlayRef} from '../overlay';
1010
import {ESCAPE, hasModifierKey} from '../keycodes';
1111
import {Observable, Subject, Subscription} from 'rxjs';
12-
import {DialogConfig} from './dialog-config';
12+
import {DialogConfig, DialogContainer} from './dialog-config';
1313
import {FocusOrigin} from '../a11y';
14-
import {BasePortalOutlet} from '../portal';
1514
import {ComponentRef} from '@angular/core';
1615

1716
/** Additional options that can be passed in when closing a dialog. */
@@ -37,7 +36,7 @@ export class DialogRef<R = unknown, C = unknown> {
3736
readonly componentRef: ComponentRef<C> | null;
3837

3938
/** Instance of the container that is rendering out the dialog content. */
40-
readonly containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin};
39+
readonly containerInstance: DialogContainer;
4140

4241
/** Whether the user is allowed to close the dialog. */
4342
disableClose: boolean | undefined;
@@ -62,7 +61,7 @@ export class DialogRef<R = unknown, C = unknown> {
6261

6362
constructor(
6463
readonly overlayRef: OverlayRef,
65-
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>,
64+
readonly config: DialogConfig<any, DialogRef<R, C>, DialogContainer>,
6665
) {
6766
this.disableClose = config.disableClose;
6867
this.backdropClick = overlayRef.backdropClick();
@@ -107,7 +106,7 @@ export class DialogRef<R = unknown, C = unknown> {
107106
closedSubject.next(result);
108107
closedSubject.complete();
109108
(this as {componentInstance: C}).componentInstance = (
110-
this as {containerInstance: BasePortalOutlet}
109+
this as {containerInstance: DialogContainer}
111110
).containerInstance = null!;
112111
}
113112
}

src/cdk/dialog/dialog.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import {
1616
ComponentRef,
1717
inject,
1818
} from '@angular/core';
19-
import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '../portal';
19+
import {ComponentPortal, TemplatePortal} from '../portal';
2020
import {of as observableOf, Observable, Subject, defer} from 'rxjs';
2121
import {DialogRef} from './dialog-ref';
22-
import {DialogConfig} from './dialog-config';
22+
import {DialogConfig, DialogContainer} from './dialog-config';
2323
import {Directionality} from '../bidi';
2424
import {_IdGenerator} from '../a11y';
2525
import {ComponentType, Overlay, OverlayRef, OverlayConfig, OverlayContainer} from '../overlay';
26-
import {startWith} from 'rxjs/operators';
26+
import {startWith, take} from 'rxjs/operators';
2727

2828
import {DEFAULT_DIALOG_CONFIG, DIALOG_DATA, DIALOG_SCROLL_STRATEGY} from './dialog-injectors';
2929
import {CdkDialogContainer} from './dialog-container';
@@ -118,14 +118,24 @@ export class Dialog implements OnDestroy {
118118
const dialogRef = new DialogRef(overlayRef, config);
119119
const dialogContainer = this._attachContainer(overlayRef, dialogRef, config);
120120

121-
(dialogRef as {containerInstance: BasePortalOutlet}).containerInstance = dialogContainer;
122-
this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config);
121+
(dialogRef as {containerInstance: DialogContainer}).containerInstance = dialogContainer;
123122

124123
// If this is the first dialog that we're opening, hide all the non-overlay content.
125124
if (!this.openDialogs.length) {
126-
this._hideNonDialogContentFromAssistiveTechnology();
125+
// Resolve this ahead of time, because some internal apps
126+
// mock it out and depend on it being synchronous.
127+
const overlayContainer = this._overlayContainer.getContainerElement();
128+
129+
if (dialogContainer._focusTrapped) {
130+
dialogContainer._focusTrapped.pipe(take(1)).subscribe(() => {
131+
this._hideNonDialogContentFromAssistiveTechnology(overlayContainer);
132+
});
133+
} else {
134+
this._hideNonDialogContentFromAssistiveTechnology(overlayContainer);
135+
}
127136
}
128137

138+
this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config);
129139
(this.openDialogs as DialogRef<R, C>[]).push(dialogRef);
130140
dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef, true));
131141
this.afterOpened.next(dialogRef);
@@ -209,14 +219,14 @@ export class Dialog implements OnDestroy {
209219
overlay: OverlayRef,
210220
dialogRef: DialogRef<R, C>,
211221
config: DialogConfig<D, DialogRef<R, C>>,
212-
): BasePortalOutlet {
222+
): DialogContainer {
213223
const userInjector = config.injector || config.viewContainerRef?.injector;
214224
const providers: StaticProvider[] = [
215225
{provide: DialogConfig, useValue: config},
216226
{provide: DialogRef, useValue: dialogRef},
217227
{provide: OverlayRef, useValue: overlay},
218228
];
219-
let containerType: Type<BasePortalOutlet>;
229+
let containerType: Type<DialogContainer>;
220230

221231
if (config.container) {
222232
if (typeof config.container === 'function') {
@@ -250,7 +260,7 @@ export class Dialog implements OnDestroy {
250260
private _attachDialogContent<R, D, C>(
251261
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
252262
dialogRef: DialogRef<R, C>,
253-
dialogContainer: BasePortalOutlet,
263+
dialogContainer: DialogContainer,
254264
config: DialogConfig<D, DialogRef<R, C>>,
255265
) {
256266
if (componentOrTemplateRef instanceof TemplateRef) {
@@ -292,7 +302,7 @@ export class Dialog implements OnDestroy {
292302
private _createInjector<R, D, C>(
293303
config: DialogConfig<D, DialogRef<R, C>>,
294304
dialogRef: DialogRef<R, C>,
295-
dialogContainer: BasePortalOutlet,
305+
dialogContainer: DialogContainer,
296306
fallbackInjector: Injector | undefined,
297307
): Injector {
298308
const userInjector = config.injector || config.viewContainerRef?.injector;
@@ -355,9 +365,7 @@ export class Dialog implements OnDestroy {
355365
}
356366

357367
/** Hides all of the content that isn't an overlay from assistive technology. */
358-
private _hideNonDialogContentFromAssistiveTechnology() {
359-
const overlayContainer = this._overlayContainer.getContainerElement();
360-
368+
private _hideNonDialogContentFromAssistiveTechnology(overlayContainer: HTMLElement) {
361369
// Ensure that the overlay container is attached to the DOM.
362370
if (overlayContainer.parentElement) {
363371
const siblings = overlayContainer.parentElement.children;

0 commit comments

Comments
 (0)